refactor(events): update event management components and remove unused files
- Replaced `LIST_PUBLIC_CATEGORIES` with `LIST_ADMIN_CATEGORIES_FLAT` in `ArticleFormModal.tsx`. - Removed the "Add Event" button from the dashboard page. - Simplified the `EventsPage` by removing the button and adjusting the layout. - Cleaned up the `AdminEventEditPage` by removing unnecessary props. - Deleted unused layout and page files related to event creation. - Updated `AdminAuthContent` to use `useAdminCitiesQuery` instead of `useDiscoveryCitiesQuery`. - Refactored `EventGuestListAccessPanel` to remove the `accessMode` prop and adjust API calls accordingly. - Removed several unused components and tests related to event creation, enhancing project maintainability.
This commit is contained in:
parent
99685e058d
commit
82593dc18c
@ -17,7 +17,7 @@ import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
|
|||||||
import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal'
|
import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal'
|
||||||
import { type BlogArticle, CREATE_ARTICLE, UPDATE_ARTICLE } from '@/services/blogArticles'
|
import { type BlogArticle, CREATE_ARTICLE, UPDATE_ARTICLE } from '@/services/blogArticles'
|
||||||
import { fetchAllCities, type City } from '@/services/geography'
|
import { fetchAllCities, type City } from '@/services/geography'
|
||||||
import { LIST_PUBLIC_CATEGORIES } from '@/services/eventCategories'
|
import { LIST_ADMIN_CATEGORIES_FLAT } from '@/services/eventCategories'
|
||||||
import {
|
import {
|
||||||
analyzeArticleBody,
|
analyzeArticleBody,
|
||||||
ARTICLE_EXCERPT_MAX,
|
ARTICLE_EXCERPT_MAX,
|
||||||
@ -246,7 +246,7 @@ const ArticleFormModal = ({ isOpen, onOpenChange, article, onSuccess }: ArticleF
|
|||||||
.then(setCities)
|
.then(setCities)
|
||||||
.catch(() => addToast({ title: 'بارگذاری فهرست شهرها ناموفق بود', color: 'danger' }))
|
.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)
|
if (result.ok) setEventCategories(result.data.items)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import BookmarkIcon from '@/components/icons/BookmarkIcon'
|
|||||||
import UserIdCardIcon from '@/components/icons/UserIdCardIcon'
|
import UserIdCardIcon from '@/components/icons/UserIdCardIcon'
|
||||||
import CoinDollarIcon from '@/components/icons/CoinDollarIcon'
|
import CoinDollarIcon from '@/components/icons/CoinDollarIcon'
|
||||||
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
|
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
|
||||||
import PlusIcon from '@/components/icons/PlusIcon'
|
|
||||||
|
|
||||||
interface StatItem {
|
interface StatItem {
|
||||||
label: string
|
label: string
|
||||||
@ -237,13 +236,6 @@ const DashboardPage = () => {
|
|||||||
مهمترین شاخصهای کاربران، رویدادها، رزروها و عملیات مالی را در یک نگاه بررسی کنید.
|
مهمترین شاخصهای کاربران، رویدادها، رزروها و عملیات مالی را در یک نگاه بررسی کنید.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<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
|
<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"
|
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`}
|
href={`${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`}
|
||||||
|
|||||||
@ -25,10 +25,8 @@ const AdminEventEditPage = () => {
|
|||||||
<PageNavbar pageTitle={texts.events.editEventTitle} />
|
<PageNavbar pageTitle={texts.events.editEventTitle} />
|
||||||
<div className="admin-page-container">
|
<div className="admin-page-container">
|
||||||
<EventEditForm
|
<EventEditForm
|
||||||
accessMode="admin"
|
|
||||||
cancelHref={detailHref}
|
cancelHref={detailHref}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
layout="admin"
|
|
||||||
successHref={detailHref}
|
successHref={detailHref}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
import type { Metadata } from 'next'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: ADMIN_PAGE_TITLES.manageEventNew,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import AdminEventCreate from '@/features/events/create/AdminEventCreate'
|
|
||||||
|
|
||||||
const AdminEventCreatePage = () => <AdminEventCreate />
|
|
||||||
|
|
||||||
export default AdminEventCreatePage
|
|
||||||
@ -3,7 +3,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import type { PaginationListColumnType } from '@/types'
|
import type { PaginationListColumnType } from '@/types'
|
||||||
import Button from '@/components/formElements/Button'
|
|
||||||
import PaginatedList from '@/components/PaginatedList'
|
import PaginatedList from '@/components/PaginatedList'
|
||||||
import PageNavbar from '@/components/layouts/PageNavbar'
|
import PageNavbar from '@/components/layouts/PageNavbar'
|
||||||
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
||||||
@ -183,17 +182,7 @@ const EventsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="h-full w-full text-right">
|
<section className="h-full w-full text-right">
|
||||||
<PageNavbar
|
<PageNavbar pageTitle="رویدادها" />
|
||||||
endSlot={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
to={APP_ROUTES.MANAGE_EVENT_NEW}
|
|
||||||
>
|
|
||||||
افزودن رویداد
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
pageTitle="رویدادها"
|
|
||||||
/>
|
|
||||||
<div className="admin-page-container">
|
<div className="admin-page-container">
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import Input from '@/components/formElements/Input'
|
|||||||
import ConsumerInput from '@/components/consumer/ConsumerInput'
|
import ConsumerInput from '@/components/consumer/ConsumerInput'
|
||||||
import { texts, format } from '@/texts'
|
import { texts, format } from '@/texts'
|
||||||
import { useAuthFlow } from '@/features/auth/useAuthFlow'
|
import { useAuthFlow } from '@/features/auth/useAuthFlow'
|
||||||
import { useDiscoveryCitiesQuery } from '@/queries/consumer/useDiscoveryCitiesQuery'
|
import { useAdminCitiesQuery } from '@/queries/admin/useAdminCitiesQuery'
|
||||||
import { showFormValidationToast } from '@/lib/formValidationToast'
|
import { showFormValidationToast } from '@/lib/formValidationToast'
|
||||||
import { OTP_LENGTH } from '@/validation/auth'
|
import { OTP_LENGTH } from '@/validation/auth'
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ const GENDER_OPTIONS = [
|
|||||||
|
|
||||||
export function AdminAuthContent() {
|
export function AdminAuthContent() {
|
||||||
const flow = useAuthFlow({})
|
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 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
|
const title = flow.step === 'otp' ? texts.auth.otpTitle : flow.step === 'profile' ? texts.auth.profileTitle : null
|
||||||
|
|
||||||
|
|||||||
@ -8,19 +8,16 @@ import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
|||||||
import {
|
import {
|
||||||
fetchEventGuestListLinks,
|
fetchEventGuestListLinks,
|
||||||
fetchEventInvitedGuests,
|
fetchEventInvitedGuests,
|
||||||
type EventGuestListAccessMode,
|
|
||||||
type EventGuestListLink,
|
type EventGuestListLink,
|
||||||
type EventInvitedGuest,
|
type EventInvitedGuest,
|
||||||
} from '@/services/eventGuestListAccess'
|
} from '@/services/eventGuestListAccess'
|
||||||
import { format, texts } from '@/texts'
|
import { format, texts } from '@/texts'
|
||||||
|
|
||||||
interface EventGuestListAccessPanelProps {
|
interface EventGuestListAccessPanelProps {
|
||||||
accessMode: EventGuestListAccessMode
|
|
||||||
eventId: string
|
eventId: string
|
||||||
surface?: 'admin' | 'consumer'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' }: EventGuestListAccessPanelProps) => {
|
const EventGuestListAccessPanel = ({ eventId }: EventGuestListAccessPanelProps) => {
|
||||||
const [links, setLinks] = useState<EventGuestListLink[]>([])
|
const [links, setLinks] = useState<EventGuestListLink[]>([])
|
||||||
const [invitedGuests, setInvitedGuests] = useState<EventInvitedGuest[]>([])
|
const [invitedGuests, setInvitedGuests] = useState<EventInvitedGuest[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@ -34,8 +31,8 @@ const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' }
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const [nextLinks, nextInvitedGuests] = await Promise.all([
|
const [nextLinks, nextInvitedGuests] = await Promise.all([
|
||||||
fetchEventGuestListLinks(eventId, accessMode),
|
fetchEventGuestListLinks(eventId),
|
||||||
fetchEventInvitedGuests(eventId, accessMode),
|
fetchEventInvitedGuests(eventId),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@ -54,12 +51,10 @@ const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' }
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [accessMode, eventId])
|
}, [eventId])
|
||||||
|
|
||||||
const surfaceClass = surface === 'admin' ? 'admin-surface' : 'consumer-surface'
|
|
||||||
|
|
||||||
return (
|
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">
|
<CardHeader className="border-b border-consumer-border px-4 py-3 text-sm font-bold text-consumer-text">
|
||||||
{texts.events.guestAccessTitle}
|
{texts.events.guestAccessTitle}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from '@heroui/react'
|
|
||||||
|
|
||||||
import type { EventAgeRestriction, EventGenderRestriction } from '@/lib/eventAudience'
|
|
||||||
import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
interface AudienceRestrictionSelectorProps {
|
|
||||||
ageRestriction: EventAgeRestriction
|
|
||||||
genderRestriction: EventGenderRestriction
|
|
||||||
disabled?: boolean
|
|
||||||
onAgeRestrictionChange: (value: EventAgeRestriction) => void
|
|
||||||
onGenderRestrictionChange: (value: EventGenderRestriction) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Content-width button groups for age and gender limits shown to event guests. */
|
|
||||||
export default function AudienceRestrictionSelector({
|
|
||||||
ageRestriction,
|
|
||||||
genderRestriction,
|
|
||||||
disabled = false,
|
|
||||||
onAgeRestrictionChange,
|
|
||||||
onGenderRestrictionChange,
|
|
||||||
}: AudienceRestrictionSelectorProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn('grid gap-4', disabled && 'pointer-events-none opacity-70')}>
|
|
||||||
<RestrictionGroup
|
|
||||||
ariaLabel={texts.events.genderRestrictionLabel}
|
|
||||||
disabled={disabled}
|
|
||||||
options={GENDER_RESTRICTION_OPTIONS}
|
|
||||||
value={genderRestriction}
|
|
||||||
onChange={(value) => {
|
|
||||||
onGenderRestrictionChange(value as EventGenderRestriction)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<RestrictionGroup
|
|
||||||
ariaLabel={texts.events.ageRestrictionLabel}
|
|
||||||
disabled={disabled}
|
|
||||||
options={AGE_RESTRICTION_OPTIONS}
|
|
||||||
value={ageRestriction}
|
|
||||||
onChange={(value) => {
|
|
||||||
onAgeRestrictionChange(value as EventAgeRestriction)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RestrictionGroup({
|
|
||||||
ariaLabel,
|
|
||||||
disabled = false,
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
ariaLabel: string
|
|
||||||
disabled?: boolean
|
|
||||||
options: readonly { name: string; selectKey: string }[]
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="flex w-full gap-1 rounded-xl bg-secondary-50 p-1"
|
|
||||||
role="group"
|
|
||||||
>
|
|
||||||
{options.map((option) => {
|
|
||||||
const active = option.selectKey === value
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={option.selectKey}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 min-h-10 px-3 text-xs font-medium rounded-xl',
|
|
||||||
active
|
|
||||||
? '[--button-bg:var(--color-secondary-30)] [--button-bg-hover:var(--color-secondary-30)] [--button-bg-pressed:var(--color-secondary-30)] [--button-fg:#ffffff]'
|
|
||||||
: '[--button-bg:var(--color-secondary-40)] [--button-bg-hover:var(--color-secondary-40)] [--button-bg-pressed:var(--color-secondary-40)] [--button-fg:var(--color-secondary-10)]'
|
|
||||||
)}
|
|
||||||
isDisabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onPress={() => {
|
|
||||||
if (disabled) return
|
|
||||||
onChange(option.selectKey)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.name}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import Input from '@/components/formElements/Input'
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
interface CancellationPolicyEditorProps {
|
|
||||||
disabled?: boolean
|
|
||||||
cancellationFeePercent: number
|
|
||||||
cancellationFeePercent12To24Hours: number
|
|
||||||
cancellationFeePercentMoreThan24Hours: number
|
|
||||||
onChange: (
|
|
||||||
field: 'cancellationFeePercent' | 'cancellationFeePercent12To24Hours' | 'cancellationFeePercentMoreThan24Hours',
|
|
||||||
value: number
|
|
||||||
) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes all three time-based cancellation fee tiers explicit to hosts.
|
|
||||||
*/
|
|
||||||
export default function CancellationPolicyEditor({
|
|
||||||
disabled = false,
|
|
||||||
cancellationFeePercent,
|
|
||||||
cancellationFeePercent12To24Hours,
|
|
||||||
cancellationFeePercentMoreThan24Hours,
|
|
||||||
onChange,
|
|
||||||
}: CancellationPolicyEditorProps) {
|
|
||||||
const tiers = [
|
|
||||||
{
|
|
||||||
field: 'cancellationFeePercentMoreThan24Hours' as const,
|
|
||||||
label: texts.events.cancellationFeeMoreThan24HoursLabel,
|
|
||||||
value: cancellationFeePercentMoreThan24Hours,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'cancellationFeePercent12To24Hours' as const,
|
|
||||||
label: texts.events.cancellationFee12To24HoursLabel,
|
|
||||||
value: cancellationFeePercent12To24Hours,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'cancellationFeePercent' as const,
|
|
||||||
label: texts.events.cancellationFeeLabel,
|
|
||||||
value: cancellationFeePercent,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{tiers.map((tier) => {
|
|
||||||
const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tier.field}
|
|
||||||
className="rounded-consumer-control bg-[#E2E2E280] p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="text-sm font-medium text-consumer-text">{tier.label}</p>
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<Input
|
|
||||||
aria-label={tier.label}
|
|
||||||
disabled={disabled}
|
|
||||||
generalType="numberInput"
|
|
||||||
inputWrapper="h-10 w-16 rounded-xl border-0 bg-white px-2 text-center text-sm font-medium text-tertiary-900 shadow-none [&_input]:text-center disabled:bg-white/70"
|
|
||||||
maxValue={100}
|
|
||||||
minValue={0}
|
|
||||||
name={tier.field}
|
|
||||||
value={normalizedValue}
|
|
||||||
onValueChange={(nextValue) => {
|
|
||||||
const next = Number(nextValue)
|
|
||||||
|
|
||||||
onChange(tier.field, Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 start-1 flex items-center text-xs text-secondary-20">٪</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import type { EventWizardFormData } from '@/components/events/create/types'
|
|
||||||
import { formatToman } from '@/components/events/create/types'
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
interface CreateEventPreviewCardProps {
|
|
||||||
categoryName?: string
|
|
||||||
cityName?: string
|
|
||||||
data: EventWizardFormData
|
|
||||||
endsAt: string
|
|
||||||
startsAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviewField {
|
|
||||||
full?: boolean
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read-only event card that mirrors the information guests see before booking. */
|
|
||||||
export default function CreateEventPreviewCard({ categoryName, cityName, data, endsAt, startsAt }: CreateEventPreviewCardProps) {
|
|
||||||
const fields = [
|
|
||||||
{ label: texts.common.category, value: categoryName || texts.status.empty },
|
|
||||||
{ label: texts.events.start, value: startsAt || texts.status.empty },
|
|
||||||
{ label: texts.events.end, value: endsAt || texts.status.empty },
|
|
||||||
{ label: texts.events.location, value: cityName || texts.status.empty },
|
|
||||||
{ label: texts.events.price, value: data.isFree ? texts.events.free : formatToman(data.price) },
|
|
||||||
{ label: texts.events.capacity, value: String(data.capacity) },
|
|
||||||
{ full: true, label: texts.events.address, value: data.address || texts.status.empty },
|
|
||||||
] satisfies PreviewField[]
|
|
||||||
const poster = [...data.media].sort((a, b) => a.sortOrder - b.sortOrder).find((item) => item.isPoster) ?? data.media[0]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="overflow-hidden rounded-consumer-surface bg-consumer-surface">
|
|
||||||
<div
|
|
||||||
aria-label={poster ? texts.events.posterAria : texts.events.noPosterAria}
|
|
||||||
className={cn(
|
|
||||||
'flex min-h-48 items-end bg-cover bg-center p-5 sm:min-h-60',
|
|
||||||
!poster && 'bg-gradient-to-br from-primary-100 to-primary-50'
|
|
||||||
)}
|
|
||||||
role="img"
|
|
||||||
style={
|
|
||||||
poster
|
|
||||||
? {
|
|
||||||
backgroundImage: `linear-gradient(to top, color-mix(in srgb, var(--color-secondary-10) 72%, transparent), transparent 62%), url("${poster.url}")`,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={poster ? 'text-white' : 'text-primary-900'}>
|
|
||||||
<p className="mb-2 text-xs font-semibold opacity-80">{categoryName || texts.events.eventCategoryFallback}</p>
|
|
||||||
<h3 className="text-xl font-extrabold sm:text-2xl">{data.title || texts.events.eventTitleLabel}</h3>
|
|
||||||
{data.shortDescription ? <p className="mt-2 max-w-2xl text-sm leading-6 opacity-90">{data.shortDescription}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 grid-cols-2 mt-2">
|
|
||||||
{fields.map((field) => (
|
|
||||||
<div
|
|
||||||
key={field.label}
|
|
||||||
className={cn('rounded-2xl bg-consumer-surface-muted p-3', field.full && 'sm:col-span-2')}
|
|
||||||
>
|
|
||||||
<p className="mb-1 flex items-center gap-1 text-xs text-secondary-20">{field.label}</p>
|
|
||||||
<p className="text-sm font-semibold text-text-dark">{field.value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,414 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
import type { PreviousAttendee } from '@/services/events'
|
|
||||||
import { addToast } from '@/lib/toast'
|
|
||||||
import WizardStepper from '@/components/events/create/WizardStepper'
|
|
||||||
import Button from '@/components/formElements/Button'
|
|
||||||
import { mapEventToWizardCloneData } from '@/components/events/create/cloneFromEvent'
|
|
||||||
import { DetailSkeleton } from '@/components/feedback/LoadingState'
|
|
||||||
import {
|
|
||||||
combineDateAndMinutes,
|
|
||||||
hasRequiredPosters,
|
|
||||||
INITIAL_WIZARD_DATA,
|
|
||||||
WIZARD_STEPS,
|
|
||||||
type EventWizardFormData,
|
|
||||||
} from '@/components/events/create/types'
|
|
||||||
import { APP_ROUTES } from '@/constants/routes'
|
|
||||||
import { createEvent, createEventFaq, fetchEventForEdit } from '@/services/events'
|
|
||||||
import { fetchEventFaqs, fetchEventMedia } from '@/services/eventDetail'
|
|
||||||
import { fetchAllCities, type City } from '@/services/geography'
|
|
||||||
import { extractServerErrorDetail } from '@/services/errorHandler'
|
|
||||||
import { GET_ME } from '@/services/users'
|
|
||||||
import useAuth from '@/hooks/useAuth'
|
|
||||||
import { clearEventDraft, loadEventDraft, saveEventDraft } from '@/features/events/eventDraftStore'
|
|
||||||
import { usePublicCategoriesQuery } from '@/queries/consumer/usePublicCategoriesQuery'
|
|
||||||
import { ANALYTICS_EVENTS, trackAnalyticsEvent } from '@/lib/analytics'
|
|
||||||
|
|
||||||
const Step1BasicInfo = dynamic(() => import('@/components/events/create/steps/Step1BasicInfo'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <DetailSkeleton />,
|
|
||||||
})
|
|
||||||
const Step2ScheduleLocation = dynamic(() => import('@/components/events/create/steps/Step2ScheduleLocation'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <DetailSkeleton />,
|
|
||||||
})
|
|
||||||
const Step3PricingCapacity = dynamic(() => import('@/components/events/create/steps/Step3PricingCapacity'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <DetailSkeleton />,
|
|
||||||
})
|
|
||||||
const Step4FaqPreview = dynamic(() => import('@/components/events/create/steps/Step4FaqPreview'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <DetailSkeleton />,
|
|
||||||
})
|
|
||||||
const Step5GuestList = dynamic(() => import('@/components/events/create/steps/Step5GuestList'), {
|
|
||||||
ssr: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface EventCreateWizardProps {
|
|
||||||
finishRoute?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EventCreateWizard({ finishRoute = APP_ROUTES.MANAGE_EVENTS }: EventCreateWizardProps = {}) {
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const cloneFromIdRef = useRef(searchParams.get('cloneFrom'))
|
|
||||||
const hasTrackedStartRef = useRef(false)
|
|
||||||
const { user } = useAuth()
|
|
||||||
const userId = user?.userId
|
|
||||||
const [step, setStep] = useState(1)
|
|
||||||
const [data, setData] = useState<EventWizardFormData>(INITIAL_WIZARD_DATA)
|
|
||||||
const [isDraftHydrated, setIsDraftHydrated] = useState(false)
|
|
||||||
const [wasDraftRestored, setWasDraftRestored] = useState(false)
|
|
||||||
const [wasCloneLoaded, setWasCloneLoaded] = useState(false)
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
|
||||||
const [createdEventId, setCreatedEventId] = useState<string | null>(null)
|
|
||||||
const [previousAttendees, setPreviousAttendees] = useState<PreviousAttendee[]>([])
|
|
||||||
const [showPostCreateInvite, setShowPostCreateInvite] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId || hasTrackedStartRef.current) return
|
|
||||||
|
|
||||||
hasTrackedStartRef.current = true
|
|
||||||
trackAnalyticsEvent(ANALYTICS_EVENTS.EVENT_CREATE_STARTED, {
|
|
||||||
creation_source: 'admin',
|
|
||||||
is_clone: Boolean(cloneFromIdRef.current),
|
|
||||||
})
|
|
||||||
}, [userId])
|
|
||||||
|
|
||||||
// Shared hook — same key/staleTime as Step1BasicInfo + CategoryBrowse.
|
|
||||||
const categoriesQuery = usePublicCategoriesQuery()
|
|
||||||
const categories = useMemo(() => categoriesQuery.data?.items ?? [], [categoriesQuery.data])
|
|
||||||
const [cities, setCities] = useState<City[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId) return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
const cloneFromId = cloneFromIdRef.current
|
|
||||||
|
|
||||||
const hydrate = async () => {
|
|
||||||
if (cloneFromId) {
|
|
||||||
try {
|
|
||||||
const [event, media, faqs] = await Promise.all([
|
|
||||||
fetchEventForEdit(cloneFromId),
|
|
||||||
fetchEventMedia(cloneFromId).catch(() => []),
|
|
||||||
fetchEventFaqs(cloneFromId).catch(() => []),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
clearEventDraft(userId)
|
|
||||||
setData(mapEventToWizardCloneData(event, media, faqs))
|
|
||||||
setStep(1)
|
|
||||||
setWasCloneLoaded(true)
|
|
||||||
setWasDraftRestored(false)
|
|
||||||
setIsDraftHydrated(true)
|
|
||||||
router.replace(pathname)
|
|
||||||
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
if (cancelled) return
|
|
||||||
addToast({ title: texts.events.wizardCloneLoadFailed, color: 'danger' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = loadEventDraft<EventWizardFormData>(userId)
|
|
||||||
|
|
||||||
if (stored) {
|
|
||||||
setData({ ...INITIAL_WIZARD_DATA, ...stored.data })
|
|
||||||
setStep(stored.step)
|
|
||||||
setWasDraftRestored(true)
|
|
||||||
setIsDraftHydrated(true)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fresh create: prefill host default venue from profile contact info.
|
|
||||||
try {
|
|
||||||
const [meResult, cityRows] = await Promise.all([GET_ME(), fetchAllCities()])
|
|
||||||
|
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
setCities(cityRows)
|
|
||||||
|
|
||||||
if (meResult.ok) {
|
|
||||||
const city = meResult.data.cityId ? cityRows.find((row) => row.id === meResult.data.cityId) : undefined
|
|
||||||
|
|
||||||
setData({
|
|
||||||
...INITIAL_WIZARD_DATA,
|
|
||||||
...(meResult.data.defaultAddress ? { address: meResult.data.defaultAddress } : {}),
|
|
||||||
...(city ? { cityId: String(city.id), provinceId: String(city.provinceId) } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Prefill is best-effort; wizard still opens with empty location.
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDraftHydrated(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
void hydrate()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
// Hydrate once per userId. pathname/router are read from the render that starts hydration.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid re-cloning after URL cleanup
|
|
||||||
}, [userId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId || !isDraftHydrated || createdEventId) return
|
|
||||||
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
saveEventDraft(userId, data, step)
|
|
||||||
}, 250)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}, [createdEventId, data, isDraftHydrated, step, userId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// دریافت استانها فعلاً غیرفعال است؛ نام شهر مستقیماً از فهرست شهرها خوانده میشود.
|
|
||||||
// void fetchProvinces().then(setProvinces)
|
|
||||||
void fetchAllCities().then(setCities)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const patchData = useCallback((patch: Partial<EventWizardFormData>) => {
|
|
||||||
setData((prev) => ({ ...prev, ...patch }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const categoryName = useMemo(() => categories.find((item) => String(item.id) === data.categoryId)?.name, [categories, data.categoryId])
|
|
||||||
|
|
||||||
// نام استان فعلاً در پیشنمایش رویداد نمایش داده نمیشود.
|
|
||||||
// const provinceName = useMemo(() => provinces.find((item) => String(item.id) === data.provinceId)?.name, [provinces, data.provinceId])
|
|
||||||
|
|
||||||
const cityName = useMemo(() => cities.find((item) => String(item.id) === data.cityId)?.name, [cities, data.cityId])
|
|
||||||
|
|
||||||
const currentStepTitle = WIZARD_STEPS.find((item) => item.key === step)?.label
|
|
||||||
|
|
||||||
const handleSaveDraft = async () => {
|
|
||||||
if (!hasRequiredPosters(data.media)) {
|
|
||||||
addToast({
|
|
||||||
title: texts.events.postersRequiredTitle,
|
|
||||||
description: texts.events.postersRequiredDescription,
|
|
||||||
color: 'danger',
|
|
||||||
})
|
|
||||||
setStep(1)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true)
|
|
||||||
|
|
||||||
let eventId: string | null = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startsAt = combineDateAndMinutes(data.startDate, data.startTime)
|
|
||||||
const endsAt = combineDateAndMinutes(data.endDate, data.endTime)
|
|
||||||
|
|
||||||
if (new Date(endsAt) <= new Date(startsAt)) {
|
|
||||||
addToast({ title: texts.validation.eventWizard.endAfterStart, color: 'danger' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await createEvent({
|
|
||||||
title: data.title,
|
|
||||||
slug: data.slug,
|
|
||||||
categoryId: Number(data.categoryId),
|
|
||||||
shortDescription: data.shortDescription || undefined,
|
|
||||||
description: data.description || undefined,
|
|
||||||
startsAt,
|
|
||||||
endsAt,
|
|
||||||
provinceId: Number(data.provinceId),
|
|
||||||
cityId: Number(data.cityId),
|
|
||||||
address: data.address,
|
|
||||||
lat: data.lat,
|
|
||||||
lng: data.lng,
|
|
||||||
isFree: data.isFree,
|
|
||||||
price: data.isFree ? 0 : data.price,
|
|
||||||
capacity: data.capacity,
|
|
||||||
reservedCapacity: data.reservedCapacity,
|
|
||||||
genderRestriction: data.genderRestriction,
|
|
||||||
ageRestriction: data.ageRestriction,
|
|
||||||
cancellationFeePercent: data.cancellationFeePercent,
|
|
||||||
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
|
|
||||||
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
|
|
||||||
settings: {
|
|
||||||
isDiscoverable: data.isDiscoverable,
|
|
||||||
autoCreateGroup: data.autoCreateGroup,
|
|
||||||
waitlistAutoOffer: data.waitlistAutoOffer,
|
|
||||||
sendReviewRequestSms: data.sendReviewRequestSms,
|
|
||||||
addressVisibility: data.addressVisibility,
|
|
||||||
generalArea: data.addressVisibility === 'attendees_only' ? data.generalArea : undefined,
|
|
||||||
},
|
|
||||||
media: data.media.map((item) => ({
|
|
||||||
mediaType: 'image' as const,
|
|
||||||
url: item.url,
|
|
||||||
sortOrder: item.sortOrder,
|
|
||||||
isPoster: item.isPoster,
|
|
||||||
isSquarePoster: item.isSquarePoster,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
eventId = event.id
|
|
||||||
setCreatedEventId(event.id)
|
|
||||||
if (userId) clearEventDraft(userId)
|
|
||||||
|
|
||||||
for (let index = 0; index < data.faqs.length; index += 1) {
|
|
||||||
const faq = data.faqs[index]
|
|
||||||
|
|
||||||
await createEventFaq(event.id, {
|
|
||||||
question: faq.question,
|
|
||||||
answer: faq.answer,
|
|
||||||
sortOrder: index,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
addToast({ title: texts.events.draftSaved, color: 'success' })
|
|
||||||
|
|
||||||
const { loadPreviousAttendeesForStep5 } = await import('@/components/events/create/steps/Step5GuestList')
|
|
||||||
const attendees = await loadPreviousAttendeesForStep5()
|
|
||||||
|
|
||||||
if (attendees.length === 0) {
|
|
||||||
router.push(finishRoute)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPreviousAttendees(attendees)
|
|
||||||
setShowPostCreateInvite(true)
|
|
||||||
} catch (error) {
|
|
||||||
const detail = extractServerErrorDetail((error as { response?: { data?: unknown } })?.response?.data)
|
|
||||||
|
|
||||||
if (eventId) {
|
|
||||||
addToast({
|
|
||||||
title: texts.events.createdPartialTitle,
|
|
||||||
description: detail ?? texts.events.createdPartialDescription,
|
|
||||||
color: 'warning',
|
|
||||||
})
|
|
||||||
router.push(APP_ROUTES.MANAGE_EVENT_DETAIL(eventId))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addToast({
|
|
||||||
title: texts.events.draftSaveFailed,
|
|
||||||
description: detail ?? undefined,
|
|
||||||
color: 'danger',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userId || !isDraftHydrated) return <DetailSkeleton />
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl border border-primary-100 bg-primary-50 px-3 py-2 text-xs text-primary sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<span>
|
|
||||||
{wasCloneLoaded
|
|
||||||
? texts.events.wizardCloneHint
|
|
||||||
: wasDraftRestored
|
|
||||||
? texts.events.wizardDraftRestoredHint
|
|
||||||
: texts.events.wizardAutosaveHint}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
className="text-xs text-secondary-20"
|
|
||||||
size="sm"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => {
|
|
||||||
if (userId) clearEventDraft(userId)
|
|
||||||
setData({ ...INITIAL_WIZARD_DATA, media: [], faqs: [] })
|
|
||||||
setStep(1)
|
|
||||||
setWasDraftRestored(false)
|
|
||||||
setWasCloneLoaded(false)
|
|
||||||
setCreatedEventId(null)
|
|
||||||
setShowPostCreateInvite(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{texts.events.startOver}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<WizardStepper currentStep={step} />
|
|
||||||
<div>
|
|
||||||
{currentStepTitle ? <h2 className="mb-6 text-lg font-semibold text-text-dark">{currentStepTitle}</h2> : null}
|
|
||||||
{step === 1 && (
|
|
||||||
<Step1BasicInfo
|
|
||||||
data={data}
|
|
||||||
onChange={patchData}
|
|
||||||
onNext={(patch) => {
|
|
||||||
patchData(patch)
|
|
||||||
setStep(2)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<Step2ScheduleLocation
|
|
||||||
data={data}
|
|
||||||
onBack={() => {
|
|
||||||
setStep(1)
|
|
||||||
}}
|
|
||||||
onChange={patchData}
|
|
||||||
onNext={(patch) => {
|
|
||||||
patchData(patch)
|
|
||||||
setStep(3)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<Step3PricingCapacity
|
|
||||||
data={data}
|
|
||||||
onBack={() => {
|
|
||||||
setStep(2)
|
|
||||||
}}
|
|
||||||
onChange={patchData}
|
|
||||||
onNext={(patch) => {
|
|
||||||
patchData(patch)
|
|
||||||
setStep(4)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 4 && (
|
|
||||||
<Step4FaqPreview
|
|
||||||
categoryName={categoryName}
|
|
||||||
cityName={cityName}
|
|
||||||
data={data}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/* استان فعلاً در پیشنمایش نمایش داده نمیشود. */
|
|
||||||
/* provinceName={provinceName} */
|
|
||||||
onBack={() => {
|
|
||||||
setStep(3)
|
|
||||||
}}
|
|
||||||
onChange={patchData}
|
|
||||||
onSaveDraft={() => void handleSaveDraft()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{createdEventId ? (
|
|
||||||
<Step5GuestList
|
|
||||||
attendees={previousAttendees}
|
|
||||||
eventId={createdEventId}
|
|
||||||
isOpen={showPostCreateInvite}
|
|
||||||
onFinish={() => {
|
|
||||||
router.push(finishRoute)
|
|
||||||
}}
|
|
||||||
onOpenChange={setShowPostCreateInvite}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Tabs } from '@heroui/react'
|
|
||||||
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
interface TicketTypeSelectorProps {
|
|
||||||
isFree: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
onChange: (isFree: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const TICKET_TYPE_TABS = [
|
|
||||||
['free', texts.events.free],
|
|
||||||
['paid', texts.events.ticketPaid],
|
|
||||||
] as const
|
|
||||||
|
|
||||||
/** Segmented free/paid ticket selection used by the consumer event wizard. */
|
|
||||||
export default function TicketTypeSelector({ isFree, disabled = false, onChange }: TicketTypeSelectorProps) {
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
aria-label={texts.events.ticketTypeSectionTitle}
|
|
||||||
className="w-full"
|
|
||||||
isDisabled={disabled}
|
|
||||||
selectedKey={isFree ? 'free' : 'paid'}
|
|
||||||
onSelectionChange={(key) => {
|
|
||||||
if (disabled) return
|
|
||||||
onChange(key === 'free')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* HeroUI tab default is h-8; list has p-1 → use !h-[46px] so total track = 54px */}
|
|
||||||
<Tabs.ListContainer className="bg-secondary-40">
|
|
||||||
<Tabs.List aria-label={texts.events.ticketTypeSectionTitle}>
|
|
||||||
{TICKET_TYPE_TABS.map(([tab, label]) => (
|
|
||||||
<Tabs.Tab
|
|
||||||
key={tab}
|
|
||||||
className="!h-[46px] p-2 text-secondary-10 data-[selected=true]:text-white font-semibold"
|
|
||||||
id={tab}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<Tabs.Indicator className="bg-secondary-30" />
|
|
||||||
</Tabs.Tab>
|
|
||||||
))}
|
|
||||||
</Tabs.List>
|
|
||||||
</Tabs.ListContainer>
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn'
|
|
||||||
import { WIZARD_STEPS } from '@/components/events/create/types'
|
|
||||||
import { texts, format } from '@/texts'
|
|
||||||
|
|
||||||
interface WizardStepperProps {
|
|
||||||
currentStep: number
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepCircle({ step, isActive, isDone }: { step: number; isActive: boolean; isDone: boolean }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'relative z-10 flex size-10 shrink-0 items-center justify-center rounded-full border-2 text-sm font-bold transition-all duration-300',
|
|
||||||
isActive && 'border-primary bg-primary text-white shadow-md shadow-primary/25 scale-110',
|
|
||||||
isDone && !isActive && 'border-fifth bg-fifth text-white',
|
|
||||||
!isActive && !isDone && 'border-default-300 bg-white text-default-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isDone && !isActive ? (
|
|
||||||
<svg
|
|
||||||
aria-hidden
|
|
||||||
className="size-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2.5}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
step
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WizardStepper({ currentStep, className }: WizardStepperProps) {
|
|
||||||
const current = WIZARD_STEPS.find((step) => step.key === currentStep)
|
|
||||||
const progress = Math.round((currentStep / WIZARD_STEPS.length) * 100)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label={texts.events.wizardStepsAria}
|
|
||||||
className={cn('w-full', className)}
|
|
||||||
>
|
|
||||||
<div className="sm:hidden">
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
|
|
||||||
<span className="font-bold text-consumer-text">
|
|
||||||
مرحله {currentStep} از {WIZARD_STEPS.length} — {current?.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-secondary-20">{progress}٪</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-label={format(texts.events.wizardProgressAria, { progress })}
|
|
||||||
aria-valuemax={100}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuenow={progress}
|
|
||||||
className="h-2 overflow-hidden rounded-full bg-default-200"
|
|
||||||
role="progressbar"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="hidden items-start justify-between gap-2 sm:flex">
|
|
||||||
{WIZARD_STEPS.map((step, index) => {
|
|
||||||
const isActive = step.key === currentStep
|
|
||||||
const isDone = step.key < currentStep
|
|
||||||
const isLast = index === WIZARD_STEPS.length - 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={step.key}
|
|
||||||
className={cn('flex flex-1 min-w-0', isLast && 'flex-none max-w-[4.5rem] md:max-w-none')}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center w-full min-w-0">
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
{/* connector before (RTL: appears on the right side visually for non-first) */}
|
|
||||||
{index > 0 ? (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
'h-1 flex-1 min-w-2 transition-colors duration-300',
|
|
||||||
step.key <= currentStep ? 'bg-fifth' : 'bg-default-200'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 min-w-2" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StepCircle
|
|
||||||
isActive={isActive}
|
|
||||||
isDone={isDone}
|
|
||||||
step={step.key}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isLast ? (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
'h-1 flex-1 min-w-2 transition-colors duration-300',
|
|
||||||
step.key < currentStep ? 'bg-fifth' : 'bg-default-200'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 min-w-2" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 text-center px-1 w-full min-w-0">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-smtext-xs font-medium leading-tight truncate',
|
|
||||||
isActive && 'text-primary font-semibold',
|
|
||||||
isDone && !isActive && 'text-fifth',
|
|
||||||
!isActive && !isDone && 'text-default-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{step.label}
|
|
||||||
</p>
|
|
||||||
{isActive ? (
|
|
||||||
<span className="inline-block mt-1 text-[10px] text-primary/80 bg-primary-50 px-2 py-1 rounded-full">
|
|
||||||
{texts.events.wizardInProgress}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import type { CreatedEvent } from '@/services/events'
|
|
||||||
import type { EventFaq, EventMedia } from '@/services/eventDetail'
|
|
||||||
import { mapEventToWizardCloneData } from '@/components/events/create/cloneFromEvent'
|
|
||||||
|
|
||||||
vi.stubGlobal('crypto', {
|
|
||||||
randomUUID: () => 'test-uuid',
|
|
||||||
})
|
|
||||||
|
|
||||||
const baseEvent = {
|
|
||||||
id: 'evt-1',
|
|
||||||
organizerId: 'user-1',
|
|
||||||
categoryId: 3,
|
|
||||||
clonedFromEventId: null,
|
|
||||||
title: 'دورهمی هفتگی',
|
|
||||||
slug: 'weekly-meetup',
|
|
||||||
shortDescription: 'خلاصه',
|
|
||||||
description: '<p>توضیح</p>',
|
|
||||||
startsAt: '2030-06-01T10:00:00.000Z',
|
|
||||||
endsAt: '2030-06-01T12:30:00.000Z',
|
|
||||||
provinceId: 8,
|
|
||||||
cityId: 301,
|
|
||||||
address: 'تهران',
|
|
||||||
lat: 35.7,
|
|
||||||
lng: 51.4,
|
|
||||||
isFree: false,
|
|
||||||
price: 150000,
|
|
||||||
capacity: 40,
|
|
||||||
reservedCapacity: 3,
|
|
||||||
bookedCount: 5,
|
|
||||||
cancellationFeePercent: 10,
|
|
||||||
cancellationFeePercent12To24Hours: 10,
|
|
||||||
cancellationFeePercentMoreThan24Hours: 10,
|
|
||||||
commissionPercent: null,
|
|
||||||
effectiveCommissionPercent: 7,
|
|
||||||
status: 'published',
|
|
||||||
isFeatured: false,
|
|
||||||
settings: {
|
|
||||||
isDiscoverable: true,
|
|
||||||
autoCreateGroup: true,
|
|
||||||
addressVisibility: 'public',
|
|
||||||
generalArea: null,
|
|
||||||
waitlistAutoOffer: true,
|
|
||||||
sendReviewRequestSms: false,
|
|
||||||
},
|
|
||||||
discoveryPlacements: [],
|
|
||||||
adminApprovedAt: null,
|
|
||||||
rejectionReason: null,
|
|
||||||
avgRating: null,
|
|
||||||
reviewsCount: 0,
|
|
||||||
publishedAt: '2030-01-01T00:00:00.000Z',
|
|
||||||
createdAt: '2030-01-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2030-01-01T00:00:00.000Z',
|
|
||||||
posterUrl: null,
|
|
||||||
} as unknown as CreatedEvent
|
|
||||||
|
|
||||||
describe('mapEventToWizardCloneData', () => {
|
|
||||||
it('prefills wizard fields from the source event', () => {
|
|
||||||
const media: EventMedia[] = [
|
|
||||||
{
|
|
||||||
id: 'm1',
|
|
||||||
eventId: 'evt-1',
|
|
||||||
mediaType: 'image',
|
|
||||||
url: 'https://cdn.example/poster.jpg',
|
|
||||||
thumbnailUrl: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
isPoster: true,
|
|
||||||
isSquarePoster: false,
|
|
||||||
createdAt: '2030-01-01T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'm2',
|
|
||||||
eventId: 'evt-1',
|
|
||||||
mediaType: 'image',
|
|
||||||
url: 'https://cdn.example/square.jpg',
|
|
||||||
thumbnailUrl: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
isPoster: false,
|
|
||||||
isSquarePoster: true,
|
|
||||||
createdAt: '2030-01-01T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const faqs: EventFaq[] = [
|
|
||||||
{
|
|
||||||
id: 'f1',
|
|
||||||
eventId: 'evt-1',
|
|
||||||
question: 'چه زمانی؟',
|
|
||||||
answer: 'شنبه',
|
|
||||||
sortOrder: 0,
|
|
||||||
createdAt: '2030-01-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2030-01-01T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = mapEventToWizardCloneData(baseEvent, media, faqs)
|
|
||||||
|
|
||||||
expect(result.title).toBe('دورهمی هفتگی (کپی)')
|
|
||||||
expect(result.slug).toBe('weekly-meetup')
|
|
||||||
expect(result.categoryId).toBe('3')
|
|
||||||
expect(result.cityId).toBe('301')
|
|
||||||
expect(result.capacity).toBe(40)
|
|
||||||
expect(result.reservedCapacity).toBe(3)
|
|
||||||
expect(result.media).toHaveLength(2)
|
|
||||||
expect(result.media[0]?.url).toBe('https://cdn.example/poster.jpg')
|
|
||||||
expect(result.media[1]?.url).toBe('https://cdn.example/square.jpg')
|
|
||||||
expect(result.faqs).toEqual([{ id: 'test-uuid', question: 'چه زمانی؟', answer: 'شنبه' }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import type { EventWizardFormData } from '@/components/events/create/types'
|
|
||||||
import { createClientId } from '@/lib/createClientId'
|
|
||||||
import type { CreatedEvent } from '@/services/events'
|
|
||||||
import type { EventFaq, EventMedia } from '@/services/eventDetail'
|
|
||||||
import { texts, format } from '@/texts'
|
|
||||||
|
|
||||||
function splitIsoToDateAndMinutes(iso: string): { date: string; minutes: number } {
|
|
||||||
const d = new Date(iso)
|
|
||||||
|
|
||||||
return {
|
|
||||||
date: d.toISOString(),
|
|
||||||
minutes: d.getHours() * 60 + d.getMinutes(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps an existing event (plus media/FAQs) into create-wizard form values
|
|
||||||
* so the host can tweak and submit a new event.
|
|
||||||
*/
|
|
||||||
export function mapEventToWizardCloneData(event: CreatedEvent, media: EventMedia[], faqs: EventFaq[]): EventWizardFormData {
|
|
||||||
const start = splitIsoToDateAndMinutes(String(event.startsAt))
|
|
||||||
const end = splitIsoToDateAndMinutes(String(event.endsAt))
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: format(texts.events.cloneTitleSuffix, { title: event.title }),
|
|
||||||
slug: event.slug,
|
|
||||||
categoryId: String(event.categoryId),
|
|
||||||
shortDescription: event.shortDescription ?? '',
|
|
||||||
description: event.description ?? '',
|
|
||||||
media: media
|
|
||||||
.filter((item) => item.mediaType === 'image')
|
|
||||||
.map((item, index) => ({
|
|
||||||
id: createClientId(),
|
|
||||||
url: item.url,
|
|
||||||
isPoster: item.isPoster,
|
|
||||||
isSquarePoster: Boolean(item.isSquarePoster),
|
|
||||||
sortOrder: item.sortOrder ?? index,
|
|
||||||
})),
|
|
||||||
startDate: start.date,
|
|
||||||
startTime: start.minutes,
|
|
||||||
endDate: end.date,
|
|
||||||
endTime: end.minutes,
|
|
||||||
provinceId: String(event.provinceId),
|
|
||||||
cityId: String(event.cityId),
|
|
||||||
// Cloning always starts from the organizer's own event, so the backend
|
|
||||||
// never masks address/lat/lng here (see EventsService.applyAddressVisibility)
|
|
||||||
// -- the `?? ` fallbacks only satisfy the DTO's general nullable type.
|
|
||||||
address: event.address ?? '',
|
|
||||||
lat: event.lat ?? 35.6892,
|
|
||||||
lng: event.lng ?? 51.389,
|
|
||||||
isFree: event.isFree,
|
|
||||||
price: event.price,
|
|
||||||
capacity: event.capacity,
|
|
||||||
reservedCapacity: event.reservedCapacity ?? 0,
|
|
||||||
genderRestriction: event.genderRestriction ?? 'open',
|
|
||||||
ageRestriction: event.ageRestriction ?? 'open',
|
|
||||||
cancellationFeePercent: event.cancellationFeePercent,
|
|
||||||
cancellationFeePercent12To24Hours: event.cancellationFeePercent12To24Hours,
|
|
||||||
cancellationFeePercentMoreThan24Hours: event.cancellationFeePercentMoreThan24Hours,
|
|
||||||
isDiscoverable: event.settings.isDiscoverable,
|
|
||||||
autoCreateGroup: event.settings.autoCreateGroup,
|
|
||||||
addressVisibility: event.settings.addressVisibility,
|
|
||||||
generalArea: event.settings.generalArea ?? '',
|
|
||||||
waitlistAutoOffer: event.settings.waitlistAutoOffer,
|
|
||||||
sendReviewRequestSms: event.settings.sendReviewRequestSms ?? true,
|
|
||||||
faqs: faqs.map((faq) => ({
|
|
||||||
id: createClientId(),
|
|
||||||
question: faq.question,
|
|
||||||
answer: faq.answer,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { FormProvider, useForm } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { texts, format } from '@/texts'
|
|
||||||
import type { EventWizardFormData } from '@/components/events/create/types'
|
|
||||||
import { hasRequiredPosters } from '@/components/events/create/types'
|
|
||||||
import { addToast } from '@/lib/toast'
|
|
||||||
import Input from '@/components/formElements/Input'
|
|
||||||
import EventMediaGalleryUploader from '@/components/events/create/EventMediaGalleryUploader'
|
|
||||||
import CategoryTreeSelect from '@/components/events/create/CategoryTreeSelect'
|
|
||||||
import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
|
|
||||||
import { EventWizardStep1Validation, type EventWizardStep1Values } from '@/validation/eventWizard'
|
|
||||||
import { shouldAutogenerateEventSlug, slugifyEventTitle } from '@/features/events/eventSlug'
|
|
||||||
import { usePublicCategoriesQuery } from '@/queries/consumer/usePublicCategoriesQuery'
|
|
||||||
import { showFormValidationToast } from '@/lib/formValidationToast'
|
|
||||||
|
|
||||||
const TextEditor = dynamic(() => import('@/components/formElements/TextEditor'), { ssr: false })
|
|
||||||
|
|
||||||
interface Step1BasicInfoProps {
|
|
||||||
data: EventWizardFormData
|
|
||||||
onChange: (patch: Partial<EventWizardFormData>) => void
|
|
||||||
onNext: (patch: Partial<EventWizardFormData>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Step1BasicInfo({ data, onChange, onNext }: Step1BasicInfoProps) {
|
|
||||||
const categoriesQuery = usePublicCategoriesQuery()
|
|
||||||
const categories = categoriesQuery.data?.items ?? []
|
|
||||||
const form = useForm<EventWizardStep1Values>({
|
|
||||||
resolver: zodResolver(EventWizardStep1Validation),
|
|
||||||
defaultValues: {
|
|
||||||
title: data.title,
|
|
||||||
slug: data.slug,
|
|
||||||
categoryId: data.categoryId,
|
|
||||||
shortDescription: data.shortDescription,
|
|
||||||
description: data.description,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const followTitleRef = useRef(shouldAutogenerateEventSlug(data.slug, data.title))
|
|
||||||
const lastAutoSlugRef = useRef(data.slug || slugifyEventTitle(data.title))
|
|
||||||
const title = form.watch('title')
|
|
||||||
const slug = form.watch('slug')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (categoriesQuery.isError) {
|
|
||||||
addToast({ title: texts.events.categoriesLoadFailed, color: 'danger' })
|
|
||||||
}
|
|
||||||
}, [categoriesQuery.isError])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!followTitleRef.current) return
|
|
||||||
|
|
||||||
const next = slugifyEventTitle(title ?? '')
|
|
||||||
|
|
||||||
lastAutoSlugRef.current = next
|
|
||||||
if (form.getValues('slug') !== next) {
|
|
||||||
form.setValue('slug', next)
|
|
||||||
}
|
|
||||||
}, [form, title])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = form.watch((values, info) => {
|
|
||||||
if (info.name === 'slug' && (values.slug ?? '') !== lastAutoSlugRef.current) {
|
|
||||||
followTitleRef.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange({
|
|
||||||
title: values.title ?? '',
|
|
||||||
slug: values.slug ?? '',
|
|
||||||
categoryId: values.categoryId ?? '',
|
|
||||||
shortDescription: values.shortDescription ?? '',
|
|
||||||
description: values.description ?? '',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.unsubscribe()
|
|
||||||
}
|
|
||||||
}, [form, onChange])
|
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
|
||||||
if (!hasRequiredPosters(data.media)) {
|
|
||||||
addToast({
|
|
||||||
title: texts.events.postersRequiredTitle,
|
|
||||||
description: texts.events.postersRequiredShort,
|
|
||||||
color: 'danger',
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onNext({
|
|
||||||
title: values.title,
|
|
||||||
slug: values.slug,
|
|
||||||
categoryId: values.categoryId,
|
|
||||||
shortDescription: values.shortDescription ?? '',
|
|
||||||
description: values.description ?? '',
|
|
||||||
media: data.media,
|
|
||||||
})
|
|
||||||
}, showFormValidationToast)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<WizardFormGrid>
|
|
||||||
<WizardSectionTitle description={texts.events.mediaSectionDescription}>پوستر و تصاویر</WizardSectionTitle>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<EventMediaGalleryUploader
|
|
||||||
items={data.media}
|
|
||||||
onChange={(media) => {
|
|
||||||
onChange({ media })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardSectionTitle description={texts.events.basicInfoSectionDescription}>معرفی رویداد</WizardSectionTitle>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="input"
|
|
||||||
label={texts.events.eventTitleLabel}
|
|
||||||
maxValue={200}
|
|
||||||
name="title"
|
|
||||||
/>
|
|
||||||
<CategoryTreeSelect
|
|
||||||
required
|
|
||||||
categories={categories}
|
|
||||||
label={texts.common.category}
|
|
||||||
name="categoryId"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<Input
|
|
||||||
generalType="textarea"
|
|
||||||
label={texts.events.shortDescriptionLabel}
|
|
||||||
name="shortDescription"
|
|
||||||
textAreaMinRows={3}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<p className="labelClass mb-2">{texts.events.fullDescriptionLabel}</p>
|
|
||||||
<TextEditor
|
|
||||||
value={form.watch('description')}
|
|
||||||
onChange={(value) => {
|
|
||||||
form.setValue('description', value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<details className="group rounded-2xl border border-consumer-border bg-consumer-surface-muted p-4">
|
|
||||||
<summary className="cursor-pointer list-none text-sm font-bold text-consumer-text marker:hidden">
|
|
||||||
{texts.events.configureEventLink}
|
|
||||||
<span className="me-2 text-xs font-normal text-secondary-20">{texts.events.advanced}</span>
|
|
||||||
</summary>
|
|
||||||
<div className="mt-4 border-t border-consumer-border pt-4">
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
description={slug ? format(texts.events.slugPublicLink, { slug }) : texts.events.slugHintFromTitle}
|
|
||||||
direction="ltr"
|
|
||||||
generalType="input"
|
|
||||||
label={texts.events.slugLabel}
|
|
||||||
maxValue={220}
|
|
||||||
name="slug"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardStepFooter
|
|
||||||
primaryLabel={texts.events.nextStep}
|
|
||||||
onPrimary={handleSubmit}
|
|
||||||
/>
|
|
||||||
</WizardFormGrid>
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,244 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { FormProvider, useForm, useWatch } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
import type { EventWizardFormData } from '@/components/events/create/types'
|
|
||||||
import Input from '@/components/formElements/Input'
|
|
||||||
import MapLocationPicker from '@/components/events/create/MapLocationPicker'
|
|
||||||
import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
|
|
||||||
import { INITIAL_WIZARD_DATA } from '@/components/events/create/types'
|
|
||||||
import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints'
|
|
||||||
import { addToast } from '@/lib/toast'
|
|
||||||
import { fetchAllCities, type City } from '@/services/geography'
|
|
||||||
import { EventWizardStep2Validation, type EventWizardStep2Values } from '@/validation/eventWizard'
|
|
||||||
import { showFormValidationToast } from '@/lib/formValidationToast'
|
|
||||||
|
|
||||||
const ADDRESS_VISIBILITY_OPTIONS = [
|
|
||||||
{ name: texts.events.addressVisibilityPublic, selectKey: 'public' },
|
|
||||||
{ name: texts.events.addressVisibilityAttendeesOnly, selectKey: 'attendees_only' },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface Step2ScheduleLocationProps {
|
|
||||||
data: EventWizardFormData
|
|
||||||
onBack: () => void
|
|
||||||
onChange: (patch: Partial<EventWizardFormData>) => void
|
|
||||||
onNext: (patch: Partial<EventWizardFormData>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Step2ScheduleLocation({ data, onBack, onChange, onNext }: Step2ScheduleLocationProps) {
|
|
||||||
const [cities, setCities] = useState<City[]>([])
|
|
||||||
|
|
||||||
const form = useForm<EventWizardStep2Values>({
|
|
||||||
resolver: zodResolver(EventWizardStep2Validation),
|
|
||||||
defaultValues: {
|
|
||||||
startDate: data.startDate,
|
|
||||||
startTime: data.startTime,
|
|
||||||
endDate: data.endDate,
|
|
||||||
endTime: data.endTime,
|
|
||||||
provinceId: data.provinceId,
|
|
||||||
cityId: data.cityId,
|
|
||||||
address: data.address,
|
|
||||||
addressVisibility: data.addressVisibility,
|
|
||||||
generalArea: data.generalArea,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addressVisibility = form.watch('addressVisibility')
|
|
||||||
const addressValue = useWatch({ control: form.control, name: 'address' })
|
|
||||||
|
|
||||||
// اگر کاربر قبلاً موقعیت را روی نقشه دستی مشخص کرده (مثلاً هنگام کپی یا ویرایش رویداد)،
|
|
||||||
// با تغییر شهر نباید آن پین بازنویسی شود.
|
|
||||||
const hasManualLocationRef = useRef(data.lat !== INITIAL_WIZARD_DATA.lat || data.lng !== INITIAL_WIZARD_DATA.lng)
|
|
||||||
const selectedCityId = useWatch({ control: form.control, name: 'cityId' })
|
|
||||||
const startDate = useWatch({ control: form.control, name: 'startDate' })
|
|
||||||
const startTime = useWatch({ control: form.control, name: 'startTime' })
|
|
||||||
const endDate = useWatch({ control: form.control, name: 'endDate' })
|
|
||||||
const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(startDate, startTime, endDate)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAllCities()
|
|
||||||
.then(setCities)
|
|
||||||
.catch(() => addToast({ title: texts.events.citiesLoadFailed, color: 'danger' }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasManualLocationRef.current) return
|
|
||||||
|
|
||||||
const selectedCity = cities.find((city) => String(city.id) === selectedCityId)
|
|
||||||
|
|
||||||
if (selectedCity?.lat != null && selectedCity?.lng != null) {
|
|
||||||
onChange({ lat: selectedCity.lat, lng: selectedCity.lng })
|
|
||||||
}
|
|
||||||
}, [cities, onChange, selectedCityId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = form.watch((values) => {
|
|
||||||
onChange({
|
|
||||||
startDate: values.startDate ?? '',
|
|
||||||
startTime: values.startTime ?? data.startTime,
|
|
||||||
endDate: values.endDate ?? '',
|
|
||||||
endTime: values.endTime ?? data.endTime,
|
|
||||||
provinceId: values.provinceId ?? '',
|
|
||||||
cityId: values.cityId ?? '',
|
|
||||||
address: values.address ?? '',
|
|
||||||
addressVisibility: values.addressVisibility ?? 'public',
|
|
||||||
generalArea: values.generalArea ?? '',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.unsubscribe()
|
|
||||||
}
|
|
||||||
}, [data.endTime, data.startTime, form, onChange])
|
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
|
||||||
const selectedCity = cities.find((city) => String(city.id) === values.cityId)
|
|
||||||
|
|
||||||
if (!selectedCity) {
|
|
||||||
form.setError('cityId', { message: texts.validation.cityRequired })
|
|
||||||
addToast({ title: texts.common.formIncomplete, description: texts.validation.cityRequired, color: 'danger' })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onNext({
|
|
||||||
startDate: values.startDate,
|
|
||||||
startTime: values.startTime,
|
|
||||||
endDate: values.endDate,
|
|
||||||
endTime: values.endTime,
|
|
||||||
provinceId: String(selectedCity.provinceId),
|
|
||||||
cityId: values.cityId,
|
|
||||||
address: values.address,
|
|
||||||
lat: data.lat,
|
|
||||||
lng: data.lng,
|
|
||||||
addressVisibility: values.addressVisibility,
|
|
||||||
generalArea: values.addressVisibility === 'attendees_only' ? (values.generalArea?.trim() ?? '') : '',
|
|
||||||
})
|
|
||||||
}, showFormValidationToast)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<WizardFormGrid>
|
|
||||||
<WizardSectionTitle description={texts.events.scheduleSectionDescription}>{texts.events.scheduleSectionTitle}</WizardSectionTitle>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="eventDatePicker"
|
|
||||||
label={texts.events.startsAtDateLabel}
|
|
||||||
minDate={todayIso}
|
|
||||||
name="startDate"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="eventTimePicker"
|
|
||||||
label={texts.events.startsAtTimeLabel}
|
|
||||||
minValue={startMinimumTime}
|
|
||||||
name="startTime"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="eventDatePicker"
|
|
||||||
label={texts.events.endsAtDateLabel}
|
|
||||||
minDate={startDate || todayIso}
|
|
||||||
name="endDate"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="eventTimePicker"
|
|
||||||
label={texts.events.endsAtTimeLabel}
|
|
||||||
minValue={endMinimumTime}
|
|
||||||
name="endTime"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WizardSectionTitle description={texts.events.venueSectionDescription}>{texts.events.venueSectionTitle}</WizardSectionTitle>
|
|
||||||
|
|
||||||
{/* انتخاب استان فعلاً غیرفعال است؛ استان از روی شهر تعیین میشود.
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="select"
|
|
||||||
label={texts.common.province}
|
|
||||||
name="provinceId"
|
|
||||||
selectKey="id"
|
|
||||||
selectOptions={provinces}
|
|
||||||
selectValue="name"
|
|
||||||
/> */}
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="select"
|
|
||||||
label={texts.common.city}
|
|
||||||
name="cityId"
|
|
||||||
selectKey="id"
|
|
||||||
selectOptions={cities}
|
|
||||||
selectValue="name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="textarea"
|
|
||||||
label={texts.events.address}
|
|
||||||
name="address"
|
|
||||||
placeholder={texts.events.addressPlaceholder}
|
|
||||||
textAreaMinRows={3}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-default-500">{texts.events.addressHostContactHint}</p>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<MapLocationPicker
|
|
||||||
displayAddress={addressValue}
|
|
||||||
lat={data.lat}
|
|
||||||
lng={data.lng}
|
|
||||||
mode="inline"
|
|
||||||
onAddressResolved={(address) => {
|
|
||||||
form.setValue('address', address, { shouldDirty: true, shouldTouch: true })
|
|
||||||
onChange({ address })
|
|
||||||
}}
|
|
||||||
onChange={({ lat, lng }) => {
|
|
||||||
hasManualLocationRef.current = true
|
|
||||||
onChange({ lat, lng })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardSectionTitle description={texts.events.addressVisibilitySectionDescription}>
|
|
||||||
{texts.events.addressVisibilitySectionTitle}
|
|
||||||
</WizardSectionTitle>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<Input
|
|
||||||
generalType="radio"
|
|
||||||
label={texts.events.addressVisibilityLabel}
|
|
||||||
name="addressVisibility"
|
|
||||||
radioOptions={ADDRESS_VISIBILITY_OPTIONS}
|
|
||||||
selectKey="selectKey"
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
{addressVisibility === 'attendees_only' && (
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="textarea"
|
|
||||||
label={texts.events.generalAreaLabel}
|
|
||||||
name="generalArea"
|
|
||||||
placeholder={texts.events.generalAreaPlaceholder}
|
|
||||||
textAreaMinRows={2}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<WizardStepFooter
|
|
||||||
primaryLabel={texts.events.nextStep}
|
|
||||||
onBack={onBack}
|
|
||||||
onPrimary={handleSubmit}
|
|
||||||
/>
|
|
||||||
</WizardFormGrid>
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { FormProvider, useForm } from 'react-hook-form'
|
|
||||||
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
import type { EventWizardFormData } from '@/components/events/create/types'
|
|
||||||
import AudienceRestrictionSelector from '@/components/events/create/AudienceRestrictionSelector'
|
|
||||||
import CancellationPolicyEditor from '@/components/events/create/CancellationPolicyEditor'
|
|
||||||
import TicketTypeSelector from '@/components/events/create/TicketTypeSelector'
|
|
||||||
import Input from '@/components/formElements/Input'
|
|
||||||
import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
|
|
||||||
import { EventWizardStep3Validation, type EventWizardStep3Values } from '@/validation/eventWizard'
|
|
||||||
import { showFormValidationToast } from '@/lib/formValidationToast'
|
|
||||||
|
|
||||||
interface Step3PricingCapacityProps {
|
|
||||||
data: EventWizardFormData
|
|
||||||
onBack: () => void
|
|
||||||
onChange: (patch: Partial<EventWizardFormData>) => void
|
|
||||||
onNext: (patch: Partial<EventWizardFormData>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Step3PricingCapacity({ data, onBack, onChange, onNext }: Step3PricingCapacityProps) {
|
|
||||||
const form = useForm<EventWizardStep3Values>({
|
|
||||||
resolver: zodResolver(EventWizardStep3Validation),
|
|
||||||
defaultValues: {
|
|
||||||
isFree: data.isFree,
|
|
||||||
price: data.price,
|
|
||||||
capacity: data.capacity,
|
|
||||||
reservedCapacity: data.reservedCapacity,
|
|
||||||
genderRestriction: data.genderRestriction,
|
|
||||||
ageRestriction: data.ageRestriction,
|
|
||||||
cancellationFeePercent: data.cancellationFeePercent,
|
|
||||||
cancellationFeePercent12To24Hours: data.cancellationFeePercent12To24Hours,
|
|
||||||
cancellationFeePercentMoreThan24Hours: data.cancellationFeePercentMoreThan24Hours,
|
|
||||||
isDiscoverable: data.isDiscoverable,
|
|
||||||
autoCreateGroup: data.autoCreateGroup,
|
|
||||||
waitlistAutoOffer: data.waitlistAutoOffer,
|
|
||||||
sendReviewRequestSms: data.sendReviewRequestSms,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const isFree = form.watch('isFree')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = form.watch((values) => {
|
|
||||||
onChange({
|
|
||||||
isFree: values.isFree ?? false,
|
|
||||||
price: Number(values.price ?? 0),
|
|
||||||
capacity: Number(values.capacity ?? 0),
|
|
||||||
reservedCapacity: Number(values.reservedCapacity ?? 0),
|
|
||||||
genderRestriction: values.genderRestriction ?? 'open',
|
|
||||||
ageRestriction: values.ageRestriction ?? 'open',
|
|
||||||
cancellationFeePercent: Number(values.cancellationFeePercent ?? 0),
|
|
||||||
cancellationFeePercent12To24Hours: Number(values.cancellationFeePercent12To24Hours ?? 0),
|
|
||||||
cancellationFeePercentMoreThan24Hours: Number(values.cancellationFeePercentMoreThan24Hours ?? 0),
|
|
||||||
isDiscoverable: values.isDiscoverable ?? false,
|
|
||||||
autoCreateGroup: values.autoCreateGroup ?? true,
|
|
||||||
waitlistAutoOffer: values.waitlistAutoOffer ?? true,
|
|
||||||
sendReviewRequestSms: values.sendReviewRequestSms ?? false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.unsubscribe()
|
|
||||||
}
|
|
||||||
}, [form, onChange])
|
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
|
||||||
onNext({
|
|
||||||
isFree: values.isFree,
|
|
||||||
price: values.isFree ? 0 : values.price,
|
|
||||||
capacity: values.capacity,
|
|
||||||
reservedCapacity: values.reservedCapacity,
|
|
||||||
genderRestriction: values.genderRestriction,
|
|
||||||
ageRestriction: values.ageRestriction,
|
|
||||||
cancellationFeePercent: values.cancellationFeePercent,
|
|
||||||
cancellationFeePercent12To24Hours: values.cancellationFeePercent12To24Hours,
|
|
||||||
cancellationFeePercentMoreThan24Hours: values.cancellationFeePercentMoreThan24Hours,
|
|
||||||
isDiscoverable: values.isDiscoverable,
|
|
||||||
autoCreateGroup: values.autoCreateGroup,
|
|
||||||
waitlistAutoOffer: values.waitlistAutoOffer,
|
|
||||||
sendReviewRequestSms: values.sendReviewRequestSms,
|
|
||||||
})
|
|
||||||
}, showFormValidationToast)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<WizardFormGrid>
|
|
||||||
<WizardSectionTitle description={texts.events.pricingSectionDescription}>
|
|
||||||
{texts.events.ticketTypeSectionTitle}
|
|
||||||
</WizardSectionTitle>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<TicketTypeSelector
|
|
||||||
isFree={isFree}
|
|
||||||
onChange={(nextIsFree) => {
|
|
||||||
form.setValue('isFree', nextIsFree, { shouldDirty: true, shouldValidate: true })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
{!isFree ? (
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
formatOptions={{ style: 'decimal' }}
|
|
||||||
generalType="numberInput"
|
|
||||||
label={texts.events.pricePerTicketLabel}
|
|
||||||
minValue={0}
|
|
||||||
name="price"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
generalType="numberInput"
|
|
||||||
label={texts.events.capacity}
|
|
||||||
minValue={1}
|
|
||||||
name="capacity"
|
|
||||||
/>
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<Input
|
|
||||||
generalType="numberInput"
|
|
||||||
label={texts.events.reservedCapacityLabel}
|
|
||||||
minValue={0}
|
|
||||||
name="reservedCapacity"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs text-secondary-20">{texts.events.reservedCapacityWizardHint}</p>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardSectionTitle description={texts.events.audienceSectionDescription}>{texts.events.audienceSectionTitle}</WizardSectionTitle>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<AudienceRestrictionSelector
|
|
||||||
ageRestriction={form.watch('ageRestriction')}
|
|
||||||
genderRestriction={form.watch('genderRestriction')}
|
|
||||||
onAgeRestrictionChange={(ageRestriction) => {
|
|
||||||
form.setValue('ageRestriction', ageRestriction, { shouldDirty: true, shouldValidate: true })
|
|
||||||
}}
|
|
||||||
onGenderRestrictionChange={(genderRestriction) => {
|
|
||||||
form.setValue('genderRestriction', genderRestriction, { shouldDirty: true, shouldValidate: true })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardSectionTitle description={texts.events.cancellationSectionDescription}>
|
|
||||||
{texts.events.cancellationSectionTitle}
|
|
||||||
</WizardSectionTitle>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<CancellationPolicyEditor
|
|
||||||
cancellationFeePercent={form.watch('cancellationFeePercent')}
|
|
||||||
cancellationFeePercent12To24Hours={form.watch('cancellationFeePercent12To24Hours')}
|
|
||||||
cancellationFeePercentMoreThan24Hours={form.watch('cancellationFeePercentMoreThan24Hours')}
|
|
||||||
onChange={(field, value) => {
|
|
||||||
form.setValue(field, value, { shouldDirty: true, shouldValidate: true })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<details className="group rounded-2xl border border-consumer-border bg-consumer-surface-muted p-4">
|
|
||||||
<summary className="cursor-pointer list-none text-sm font-bold text-consumer-text marker:hidden">
|
|
||||||
{texts.events.moreSettings}
|
|
||||||
<span className="me-2 text-xs font-normal text-secondary-20">{texts.events.advancedSettingsHint}</span>
|
|
||||||
</summary>
|
|
||||||
<div className="mt-4 grid gap-5 border-t border-consumer-border pt-4 md:grid-cols-2">
|
|
||||||
<Input
|
|
||||||
generalType="switch"
|
|
||||||
label={texts.events.discoverableLabel}
|
|
||||||
name="isDiscoverable"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
generalType="switch"
|
|
||||||
label={texts.events.autoCreateGroupLabel}
|
|
||||||
name="autoCreateGroup"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
generalType="switch"
|
|
||||||
label={texts.events.waitlistAutoOfferLabel}
|
|
||||||
name="waitlistAutoOffer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
generalType="switch"
|
|
||||||
label={texts.events.sendReviewSmsLabel}
|
|
||||||
name="sendReviewRequestSms"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardStepFooter
|
|
||||||
primaryLabel={texts.events.nextStep}
|
|
||||||
onBack={onBack}
|
|
||||||
onPrimary={handleSubmit}
|
|
||||||
/>
|
|
||||||
</WizardFormGrid>
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
import CreateEventPreviewCard from '@/components/events/create/CreateEventPreviewCard'
|
|
||||||
import FaqEditor from '@/components/events/create/FaqEditor'
|
|
||||||
import { WizardFormFullWidth, WizardFormGrid, WizardSectionTitle, WizardStepFooter } from '@/components/events/create/WizardFormLayout'
|
|
||||||
import { combineDateAndMinutes, formatWizardDate, type EventWizardFormData } from '@/components/events/create/types'
|
|
||||||
|
|
||||||
interface Step4FaqPreviewProps {
|
|
||||||
data: EventWizardFormData
|
|
||||||
categoryName?: string
|
|
||||||
// استان فعلاً در پیشنمایش نمایش داده نمیشود.
|
|
||||||
// provinceName?: string
|
|
||||||
cityName?: string
|
|
||||||
isSaving: boolean
|
|
||||||
onBack: () => void
|
|
||||||
onChange: (patch: Partial<EventWizardFormData>) => void
|
|
||||||
onSaveDraft: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Step4FaqPreview({
|
|
||||||
data,
|
|
||||||
categoryName,
|
|
||||||
// provinceName,
|
|
||||||
cityName,
|
|
||||||
isSaving,
|
|
||||||
onBack,
|
|
||||||
onChange,
|
|
||||||
onSaveDraft,
|
|
||||||
}: Step4FaqPreviewProps) {
|
|
||||||
let startsAtPreview = '—'
|
|
||||||
let endsAtPreview = '—'
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (data.startDate) {
|
|
||||||
startsAtPreview = formatWizardDate(combineDateAndMinutes(data.startDate, data.startTime))
|
|
||||||
}
|
|
||||||
if (data.endDate) {
|
|
||||||
endsAtPreview = formatWizardDate(combineDateAndMinutes(data.endDate, data.endTime))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// preview only
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WizardFormGrid>
|
|
||||||
<WizardSectionTitle description={texts.events.previewSectionDescription}>{texts.events.previewEventTitle}</WizardSectionTitle>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<CreateEventPreviewCard
|
|
||||||
categoryName={categoryName}
|
|
||||||
cityName={cityName}
|
|
||||||
data={data}
|
|
||||||
endsAt={endsAtPreview}
|
|
||||||
startsAt={startsAtPreview}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<div className="rounded-2xl border border-fifth/20 bg-fifth/5 p-4">
|
|
||||||
<p className="text-sm font-bold text-text-dark">{texts.events.readyToSave}</p>
|
|
||||||
<ul className="mt-3 grid gap-2 text-xs text-text-muted sm:grid-cols-2">
|
|
||||||
<li>✓ {texts.events.checklistIntroDone}</li>
|
|
||||||
<li>✓ {texts.events.checklistScheduleDone}</li>
|
|
||||||
<li>✓ {texts.events.checklistPricingDone}</li>
|
|
||||||
<li>✓ {texts.events.checklistPosterDone}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardSectionTitle description={texts.events.faqSectionDescription}>{texts.events.faqs}</WizardSectionTitle>
|
|
||||||
|
|
||||||
<WizardFormFullWidth>
|
|
||||||
<FaqEditor
|
|
||||||
items={data.faqs}
|
|
||||||
onChange={(faqs) => {
|
|
||||||
onChange({ faqs })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</WizardFormFullWidth>
|
|
||||||
|
|
||||||
<WizardStepFooter
|
|
||||||
isLoading={isSaving}
|
|
||||||
primaryLabel={texts.events.saveDraft}
|
|
||||||
primaryType="button"
|
|
||||||
onBack={onBack}
|
|
||||||
onPrimary={onSaveDraft}
|
|
||||||
/>
|
|
||||||
</WizardFormGrid>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
import Modal from '@/components/modals/Modal'
|
|
||||||
import Input from '@/components/formElements/Input'
|
|
||||||
import SearchAltIcon from '@/components/icons/SearchAltIcon'
|
|
||||||
import { coerceToString, formatPersonName } from '@/helpers'
|
|
||||||
import { addToast } from '@/lib/toast'
|
|
||||||
import { fetchPreviousAttendees, setPreviousAttendeeGuests, type PreviousAttendee } from '@/services/events'
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
interface Step5GuestListProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
eventId: string
|
|
||||||
attendees: PreviousAttendee[]
|
|
||||||
onFinish: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Step5GuestList({ isOpen, onOpenChange, eventId, attendees, onFinish }: Step5GuestListProps) {
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
||||||
const [isLinking, setIsLinking] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return
|
|
||||||
|
|
||||||
setSearch('')
|
|
||||||
setSelectedIds([])
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const filteredAttendees = useMemo(() => {
|
|
||||||
const query = search.trim().toLowerCase()
|
|
||||||
|
|
||||||
if (!query) return attendees
|
|
||||||
|
|
||||||
return attendees.filter((attendee) => {
|
|
||||||
const name = formatPersonName(attendee.firstName, attendee.lastName, '').toLowerCase()
|
|
||||||
|
|
||||||
return name.includes(query)
|
|
||||||
})
|
|
||||||
}, [attendees, search])
|
|
||||||
|
|
||||||
const allFilteredSelected = filteredAttendees.length > 0 && filteredAttendees.every((attendee) => selectedIds.includes(attendee.userId))
|
|
||||||
|
|
||||||
const toggleAttendee = (userId: string, checked: boolean) => {
|
|
||||||
setSelectedIds((prev) => (checked ? [...prev, userId] : prev.filter((id) => id !== userId)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
|
||||||
const filteredIds = filteredAttendees.map((attendee) => attendee.userId)
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
setSelectedIds((prev) => Array.from(new Set([...prev, ...filteredIds])))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedIds((prev) => prev.filter((id) => !filteredIds.includes(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSendInvitations = async () => {
|
|
||||||
if (selectedIds.length === 0) {
|
|
||||||
onOpenChange(false)
|
|
||||||
onFinish()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLinking(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setPreviousAttendeeGuests(eventId, selectedIds)
|
|
||||||
addToast({ title: texts.events.guestsSaved, color: 'success' })
|
|
||||||
onOpenChange(false)
|
|
||||||
onFinish()
|
|
||||||
} catch {
|
|
||||||
addToast({ title: texts.events.guestsSaveFailed, color: 'danger' })
|
|
||||||
} finally {
|
|
||||||
setIsLinking(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
acceptBtnDisabled={selectedIds.length === 0}
|
|
||||||
acceptBtnText={texts.events.sendInvitation}
|
|
||||||
bodyClassName="max-h-[min(60vh,28rem)]"
|
|
||||||
isLoading={isLinking}
|
|
||||||
isOpen={isOpen}
|
|
||||||
rejectBtnText={texts.common.cancel}
|
|
||||||
size="lg"
|
|
||||||
title={texts.events.invitePreviousGuestsTitle}
|
|
||||||
onAccept={() => void handleSendInvitations()}
|
|
||||||
onClose={onFinish}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<p className="text-sm leading-6 text-secondary-30">{texts.events.invitePreviousGuestsDescription}</p>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
isClearable
|
|
||||||
ariaLabel={texts.events.guestSearchAria}
|
|
||||||
generalType="input"
|
|
||||||
iconStart={<SearchAltIcon className="size-5 text-secondary-30" />}
|
|
||||||
name="guestSearch"
|
|
||||||
placeholder={texts.events.guestSearchPlaceholder}
|
|
||||||
value={search}
|
|
||||||
onClear={() => {
|
|
||||||
setSearch('')
|
|
||||||
}}
|
|
||||||
onValueChange={(next) => {
|
|
||||||
setSearch(coerceToString(next))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
generalType="checkbox"
|
|
||||||
label={texts.events.selectAllGuests}
|
|
||||||
name="selectAllGuests"
|
|
||||||
value={allFilteredSelected}
|
|
||||||
onValueChange={(checked) => {
|
|
||||||
toggleSelectAll(Boolean(checked))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{filteredAttendees.length === 0 ? (
|
|
||||||
<p className="rounded-xl bg-default-100 p-4 text-sm text-secondary-30">{texts.common.emptyChoice}</p>
|
|
||||||
) : (
|
|
||||||
filteredAttendees.map((attendee) => (
|
|
||||||
<div
|
|
||||||
key={attendee.userId}
|
|
||||||
className="rounded-xl border border-default-200 bg-white p-3"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
generalType="checkbox"
|
|
||||||
label={formatPersonName(attendee.firstName, attendee.lastName, texts.events.guest)}
|
|
||||||
name={`guest-${attendee.userId}`}
|
|
||||||
value={selectedIds.includes(attendee.userId)}
|
|
||||||
onValueChange={(checked) => {
|
|
||||||
toggleAttendee(attendee.userId, Boolean(checked))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPreviousAttendeesForStep5(): Promise<PreviousAttendee[]> {
|
|
||||||
try {
|
|
||||||
return await fetchPreviousAttendees()
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,4 @@
|
|||||||
import { formatNumber } from '@/helpers'
|
import { texts } from '@/texts'
|
||||||
import { formatPersianDate } from '@/lib/formatters'
|
|
||||||
import type { EventAgeRestriction, EventGenderRestriction } from '@/lib/eventAudience'
|
|
||||||
import { texts, format } from '@/texts'
|
|
||||||
|
|
||||||
export interface StagedMediaItem {
|
export interface StagedMediaItem {
|
||||||
id: string
|
id: string
|
||||||
@ -21,85 +18,6 @@ export interface FaqItem {
|
|||||||
answer: string
|
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 {
|
export function combineDateAndMinutes(dateIso: string, minutes: number): string {
|
||||||
const date = new Date(dateIso)
|
const date = new Date(dateIso)
|
||||||
|
|
||||||
@ -114,18 +32,3 @@ export function combineDateAndMinutes(dateIso: string, minutes: number): string
|
|||||||
|
|
||||||
return date.toISOString()
|
return date.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatWizardDate(value: string): string {
|
|
||||||
return formatPersianDate(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatToman(value: number): string {
|
|
||||||
return format(texts.events.formatToman, { value: formatNumber(value) })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMinutesAsTime(minutes: number): string {
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
const mins = minutes % 60
|
|
||||||
|
|
||||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { APP_ROUTES, CONSUMER_ROUTES, SEO_ROUTES } from '@/constants/routes'
|
|||||||
describe('event route ownership', () => {
|
describe('event route ownership', () => {
|
||||||
it('keeps admin event workflows under /manage-events', () => {
|
it('keeps admin event workflows under /manage-events', () => {
|
||||||
expect(APP_ROUTES.MANAGE_EVENTS).toBe('/manage-events')
|
expect(APP_ROUTES.MANAGE_EVENTS).toBe('/manage-events')
|
||||||
expect(APP_ROUTES.MANAGE_EVENT_NEW).toBe('/manage-events/new')
|
|
||||||
expect(APP_ROUTES.MANAGE_EVENT_DETAIL('event/id')).toBe('/manage-events/event%2Fid')
|
expect(APP_ROUTES.MANAGE_EVENT_DETAIL('event/id')).toBe('/manage-events/event%2Fid')
|
||||||
expect(APP_ROUTES.MANAGE_EVENT_EDIT('event/id')).toBe('/manage-events/event%2Fid/edit')
|
expect(APP_ROUTES.MANAGE_EVENT_EDIT('event/id')).toBe('/manage-events/event%2Fid/edit')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -3,7 +3,6 @@ export const APP_ROUTES = {
|
|||||||
USERS: '/users',
|
USERS: '/users',
|
||||||
USER_DETAIL: (id: string) => `/users/${id}`,
|
USER_DETAIL: (id: string) => `/users/${id}`,
|
||||||
MANAGE_EVENTS: '/manage-events',
|
MANAGE_EVENTS: '/manage-events',
|
||||||
MANAGE_EVENT_NEW: '/manage-events/new',
|
|
||||||
MANAGE_EVENT_DETAIL: (id: string) => `/manage-events/${encodeURIComponent(id)}`,
|
MANAGE_EVENT_DETAIL: (id: string) => `/manage-events/${encodeURIComponent(id)}`,
|
||||||
MANAGE_EVENT_DISCOUNTS: (id: string) => `/manage-events/${encodeURIComponent(id)}?tab=discounts`,
|
MANAGE_EVENT_DISCOUNTS: (id: string) => `/manage-events/${encodeURIComponent(id)}?tab=discounts`,
|
||||||
MANAGE_EVENT_EDIT: (id: string) => `/manage-events/${encodeURIComponent(id)}/edit`,
|
MANAGE_EVENT_EDIT: (id: string) => `/manage-events/${encodeURIComponent(id)}/edit`,
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { hashKey } from '@tanstack/react-query'
|
|
||||||
import { jwtDecode } from 'jwt-decode'
|
|
||||||
import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'
|
import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { jwtDecode } from 'jwt-decode'
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -21,13 +20,12 @@ import { parseRefreshExpiry, REFRESH_COOKIE_DAYS } from '@/lib/refreshTokenExpir
|
|||||||
import { convertPersianToEnglish } from '@/helpers'
|
import { convertPersianToEnglish } from '@/helpers'
|
||||||
import { texts } from '@/texts'
|
import { texts } from '@/texts'
|
||||||
import { REQUEST_OTP, VERIFY_OTP } from '@/services/auth'
|
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 { unsubscribeCurrentDevicePush } from '@/services/push'
|
||||||
import axiosInstance, { refreshAccessTokenProactively } from '@/config/axios'
|
import axiosInstance, { refreshAccessTokenProactively } from '@/config/axios'
|
||||||
import useLoading from '@/hooks/useLoading'
|
import useLoading from '@/hooks/useLoading'
|
||||||
import { clearAsyncResourceCache } from '@/hooks/useAsyncResource'
|
import { clearAsyncResourceCache } from '@/hooks/useAsyncResource'
|
||||||
import { clearPersistedQueryCache, getQueryClient } from '@/lib/queryClient'
|
import { clearPersistedQueryCache, getQueryClient } from '@/lib/queryClient'
|
||||||
import { consumerKeys } from '@/queries/consumerKeys'
|
|
||||||
import { setAccountSuspended } from '@/lib/accountSuspension'
|
import { setAccountSuspended } from '@/lib/accountSuspension'
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
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 (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ user, isSessionHydrated, sendLoginOtp, checkLoginOtp, completeProfile, logout, saveUser, updateUserFromOutside }}
|
value={{ user, isSessionHydrated, sendLoginOtp, checkLoginOtp, completeProfile, logout, saveUser, updateUserFromOutside }}
|
||||||
|
|||||||
@ -1,13 +1,8 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
import { createHostEvent, hostInsights } from '@/e2e/fixtures/hostEvent'
|
import { createHostEvent, hostInsights } from '@/e2e/fixtures/hostEvent'
|
||||||
import { authenticateAs } from '@/e2e/fixtures/session'
|
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({
|
const event = createHostEvent({
|
||||||
title: 'رویداد تست مدیریت',
|
title: 'رویداد تست مدیریت',
|
||||||
status: 'draft',
|
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('سارا احمدی', { exact: true })).toBeVisible()
|
||||||
await expect(eventRow.getByText('۴۵۰٬۰۰۰ تومان')).toBeVisible()
|
await expect(eventRow.getByText('۴۵۰٬۰۰۰ تومان')).toBeVisible()
|
||||||
expect(listRequestUrl?.pathname).toBe('/api/v1/admin/events')
|
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 }) => {
|
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.getByText('منتشرشده', { exact: true })).toBeVisible()
|
||||||
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates a complete event draft through the multi-step wizard', async ({ page }) => {
|
|
||||||
let createPayload: Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
await page.route('**/api/v1/event-categories**', (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
json: {
|
|
||||||
success: true,
|
|
||||||
data: [{ id: 10, parentId: null, name: 'ورزشی', slug: 'sports', sortOrder: 1 }],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
await page.route('**/api/v1/geography/cities**', (route) =>
|
|
||||||
route.fulfill({ json: { success: true, data: [{ id: 2, provinceId: 1, name: 'تهران', slug: 'tehran', lat: 35.7, lng: 51.4 }] } })
|
|
||||||
)
|
|
||||||
await page.route('**/api/v1/events', async (route) => {
|
|
||||||
if (route.request().method() !== 'POST') {
|
|
||||||
await route.fulfill({ json: { success: true, data: { events: [], response: { totalItemsCount: 0 } } } })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createPayload = route.request().postDataJSON() as Record<string, unknown>
|
|
||||||
await route.fulfill({ json: { success: true, data: { id: 'created-event', title: createPayload.title, status: 'draft' } } })
|
|
||||||
})
|
|
||||||
await page.route('**/api/v1/organizer-guest-lists?**', (route) => route.fulfill({ json: { success: true, data: { items: [] } } }))
|
|
||||||
|
|
||||||
await page.goto('/manage-events/new')
|
|
||||||
await page.getByLabel('عنوان رویداد').fill('دورهمی تست تولید')
|
|
||||||
await page.getByRole('button', { name: 'دستهبندی' }).click()
|
|
||||||
await page.getByRole('option', { name: 'ورزشی' }).click()
|
|
||||||
await page.getByRole('button', { name: 'مرحله بعد' }).click()
|
|
||||||
|
|
||||||
await confirmEventScheduleField(page, 'تاریخ شروع')
|
|
||||||
await confirmEventScheduleField(page, 'تاریخ پایان')
|
|
||||||
await page.getByRole('button', { name: 'شهر' }).click()
|
|
||||||
await page.getByRole('option', { name: 'تهران' }).click()
|
|
||||||
await page.getByRole('textbox', { name: 'آدرس *' }).fill('تهران، میدان آزادی')
|
|
||||||
await page.getByRole('button', { name: 'مرحله بعد' }).click()
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'رایگان' }).click()
|
|
||||||
await page.getByRole('textbox', { name: 'ظرفیت *', exact: true }).fill('50')
|
|
||||||
await page.getByRole('button', { name: 'مرحله بعد' }).click()
|
|
||||||
await page.getByRole('button', { name: 'ذخیره پیشنویس' }).click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/manage-events$/)
|
|
||||||
expect(createPayload).toMatchObject({
|
|
||||||
title: 'دورهمی تست تولید',
|
|
||||||
slug: expect.stringMatching(/^event-/),
|
|
||||||
categoryId: 10,
|
|
||||||
provinceId: 1,
|
|
||||||
cityId: 2,
|
|
||||||
address: 'تهران، میدان آزادی',
|
|
||||||
isFree: true,
|
|
||||||
price: 0,
|
|
||||||
capacity: 50,
|
|
||||||
settings: { isDiscoverable: true, autoCreateGroup: true, waitlistAutoOffer: true, addressVisibility: 'public' },
|
|
||||||
})
|
|
||||||
expect(new Date(String(createPayload?.endsAt)).getTime()).toBeGreaterThan(new Date(String(createPayload?.startsAt)).getTime())
|
|
||||||
})
|
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic'
|
|
||||||
import { Suspense } from 'react'
|
|
||||||
|
|
||||||
import PageNavbar from '@/components/layouts/PageNavbar'
|
|
||||||
import { DetailSkeleton } from '@/components/feedback/LoadingState'
|
|
||||||
|
|
||||||
const EventCreateWizard = dynamic(() => import('@/components/events/create/EventCreateWizard'), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <DetailSkeleton />,
|
|
||||||
})
|
|
||||||
|
|
||||||
const AdminEventCreate = () => {
|
|
||||||
return (
|
|
||||||
<section className="h-full w-full">
|
|
||||||
<PageNavbar pageTitle="ایجاد رویداد جدید" />
|
|
||||||
<div className="admin-page-container">
|
|
||||||
<Suspense fallback={<DetailSkeleton />}>
|
|
||||||
<EventCreateWizard />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminEventCreate
|
|
||||||
@ -438,11 +438,7 @@ const AdminEventDetail = () => {
|
|||||||
key="guest-lists"
|
key="guest-lists"
|
||||||
title="مهمانان ویژه"
|
title="مهمانان ویژه"
|
||||||
>
|
>
|
||||||
<EventGuestListAccessPanel
|
<EventGuestListAccessPanel eventId={eventId} />
|
||||||
accessMode="admin"
|
|
||||||
eventId={eventId}
|
|
||||||
surface="admin"
|
|
||||||
/>
|
|
||||||
</Tab>
|
</Tab>
|
||||||
</AppTabs>
|
</AppTabs>
|
||||||
|
|
||||||
|
|||||||
@ -36,13 +36,6 @@ const AdminEventLifecycleActions = ({
|
|||||||
ویرایش
|
ویرایش
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
to={`${APP_ROUTES.MANAGE_EVENT_NEW}?cloneFrom=${encodeURIComponent(event.id)}`}
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
کپی
|
|
||||||
</Button>
|
|
||||||
{event.status === 'draft' && !event.adminApprovedAt ? (
|
{event.status === 'draft' && !event.adminApprovedAt ? (
|
||||||
<Button
|
<Button
|
||||||
isLoading={pendingId === 'approve'}
|
isLoading={pendingId === 'approve'}
|
||||||
|
|||||||
@ -19,10 +19,7 @@ const AdminEventReviewsTab = ({ eventId }: AdminEventReviewsTabProps) => (
|
|||||||
مشاهده در لیست کلی
|
مشاهده در لیست کلی
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<EventReviewsPanel
|
<EventReviewsPanel eventId={eventId} />
|
||||||
eventId={eventId}
|
|
||||||
mode="admin"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@ const fetchEventCategories = vi.fn()
|
|||||||
const fetchAllCities = vi.fn()
|
const fetchAllCities = vi.fn()
|
||||||
const fetchEventMedia = vi.fn()
|
const fetchEventMedia = vi.fn()
|
||||||
const fetchEventFaqs = vi.fn()
|
const fetchEventFaqs = vi.fn()
|
||||||
const updateEvent = vi.fn()
|
|
||||||
const updateEventAsAdmin = vi.fn()
|
const updateEventAsAdmin = vi.fn()
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
|
vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
|
||||||
@ -19,11 +18,9 @@ vi.mock('next/dynamic', () => ({
|
|||||||
default: () => () => null,
|
default: () => () => null,
|
||||||
}))
|
}))
|
||||||
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) }))
|
||||||
vi.mock('@/hooks/useAuth', () => ({ default: () => ({ user: { userId: 'user-id' } }) }))
|
|
||||||
vi.mock('@/services/events', () => ({
|
vi.mock('@/services/events', () => ({
|
||||||
fetchEventForEdit: (...args: unknown[]) => fetchEventForEdit(...args),
|
fetchEventForEdit: (...args: unknown[]) => fetchEventForEdit(...args),
|
||||||
fetchEventCategories: (...args: unknown[]) => fetchEventCategories(...args),
|
fetchEventCategories: (...args: unknown[]) => fetchEventCategories(...args),
|
||||||
updateEvent: (...args: unknown[]) => updateEvent(...args),
|
|
||||||
updateEventAsAdmin: (...args: unknown[]) => updateEventAsAdmin(...args),
|
updateEventAsAdmin: (...args: unknown[]) => updateEventAsAdmin(...args),
|
||||||
}))
|
}))
|
||||||
vi.mock('@/services/eventDetail', () => ({
|
vi.mock('@/services/eventDetail', () => ({
|
||||||
@ -62,17 +59,13 @@ vi.mock('@/components/events/create/MapLocationPicker', () => ({
|
|||||||
default: () => <div data-testid="map-picker" />,
|
default: () => <div data-testid="map-picker" />,
|
||||||
}))
|
}))
|
||||||
vi.mock('@/components/forms/UnsavedChangesIndicator', () => ({ default: () => null }))
|
vi.mock('@/components/forms/UnsavedChangesIndicator', () => ({ default: () => null }))
|
||||||
vi.mock('@/components/forms/ConsumerFormLayout', () => ({
|
vi.mock('@/components/forms/AdminFormLayout', () => ({
|
||||||
ConsumerFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => (
|
AdminFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||||
<section>
|
<section>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</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>,
|
AdminFormActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
}))
|
}))
|
||||||
vi.mock('@/components/formElements/Button', () => ({
|
vi.mock('@/components/formElements/Button', () => ({
|
||||||
@ -194,7 +187,7 @@ describe('EventEditForm', () => {
|
|||||||
fetchAllCities.mockResolvedValue([{ id: 2, name: 'تهران', provinceId: 1 }])
|
fetchAllCities.mockResolvedValue([{ id: 2, name: 'تهران', provinceId: 1 }])
|
||||||
fetchEventMedia.mockResolvedValue(requiredMedia)
|
fetchEventMedia.mockResolvedValue(requiredMedia)
|
||||||
fetchEventFaqs.mockResolvedValue([])
|
fetchEventFaqs.mockResolvedValue([])
|
||||||
updateEvent.mockImplementation(async (_id: string, payload: Record<string, unknown>) => ({
|
updateEventAsAdmin.mockImplementation(async (_id: string, payload: Record<string, unknown>) => ({
|
||||||
...baseEvent,
|
...baseEvent,
|
||||||
...payload,
|
...payload,
|
||||||
title: typeof payload.title === 'string' ? payload.title : baseEvent.title,
|
title: typeof payload.title === 'string' ? payload.title : baseEvent.title,
|
||||||
@ -260,9 +253,9 @@ describe('EventEditForm', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' }))
|
fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' }))
|
||||||
|
|
||||||
await waitFor(() => {
|
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.title).toBe('عنوان جدید')
|
||||||
expect(payload).toHaveProperty('slug')
|
expect(payload).toHaveProperty('slug')
|
||||||
@ -276,7 +269,6 @@ describe('EventEditForm', () => {
|
|||||||
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
|
expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours')
|
||||||
expect(payload).not.toHaveProperty('genderRestriction')
|
expect(payload).not.toHaveProperty('genderRestriction')
|
||||||
expect(payload).not.toHaveProperty('ageRestriction')
|
expect(payload).not.toHaveProperty('ageRestriction')
|
||||||
expect(updateEventAsAdmin).not.toHaveBeenCalled()
|
|
||||||
expect(push).toHaveBeenCalledWith('/my-events')
|
expect(push).toHaveBeenCalledWith('/my-events')
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -304,6 +296,6 @@ describe('EventEditForm', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
expect(updateEvent).not.toHaveBeenCalled()
|
expect(updateEventAsAdmin).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { FormProvider, useForm } from 'react-hook-form'
|
import { FormProvider, useForm } from 'react-hook-form'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
import { texts, format } from '@/texts'
|
import { texts, format } from '@/texts'
|
||||||
import type { CreateEventPayload, CreatedEvent } from '@/services/events'
|
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 { DetailSkeleton } from '@/components/feedback/LoadingState'
|
||||||
import InlineNotice from '@/components/feedback/InlineNotice'
|
import InlineNotice from '@/components/feedback/InlineNotice'
|
||||||
import { AdminFormActions, AdminFormSection } from '@/components/forms/AdminFormLayout'
|
import { AdminFormActions, AdminFormSection } from '@/components/forms/AdminFormLayout'
|
||||||
import { ConsumerFormActions, ConsumerFormSection } from '@/components/forms/ConsumerFormLayout'
|
|
||||||
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
|
import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator'
|
||||||
import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints'
|
import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints'
|
||||||
import { isEventSlugLocked, isFieldLocked, resolveEventEditPolicy } from '@/features/events/eventEditPolicy'
|
import { isEventSlugLocked, isFieldLocked, resolveEventEditPolicy } from '@/features/events/eventEditPolicy'
|
||||||
@ -28,15 +26,12 @@ import { syncEventFaqs, syncEventMedia } from '@/features/events/edit/syncEventE
|
|||||||
import {
|
import {
|
||||||
fetchEventCategories,
|
fetchEventCategories,
|
||||||
fetchEventForEdit,
|
fetchEventForEdit,
|
||||||
updateEvent,
|
|
||||||
updateEventAsAdmin,
|
updateEventAsAdmin,
|
||||||
type EventCategory,
|
type EventCategory,
|
||||||
type EventEditAccessMode,
|
|
||||||
} from '@/services/events'
|
} from '@/services/events'
|
||||||
import { fetchEventFaqs, fetchEventMedia, type EventFaq, type EventMedia } from '@/services/eventDetail'
|
import { fetchEventFaqs, fetchEventMedia, type EventFaq, type EventMedia } from '@/services/eventDetail'
|
||||||
import { fetchAllCities, type City } from '@/services/geography'
|
import { fetchAllCities, type City } from '@/services/geography'
|
||||||
import { extractServerErrorDetail } from '@/services/errorHandler'
|
import { extractServerErrorDetail } from '@/services/errorHandler'
|
||||||
import useAuth from '@/hooks/useAuth'
|
|
||||||
import { addToast } from '@/lib/toast'
|
import { addToast } from '@/lib/toast'
|
||||||
import { showFormValidationToast } from '@/lib/formValidationToast'
|
import { showFormValidationToast } from '@/lib/formValidationToast'
|
||||||
import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience'
|
import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience'
|
||||||
@ -80,10 +75,6 @@ const ADDRESS_VISIBILITY_OPTIONS = [
|
|||||||
|
|
||||||
interface EventEditFormProps {
|
interface EventEditFormProps {
|
||||||
eventId: string
|
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
|
cancelHref: string
|
||||||
successHref: 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 router = useRouter()
|
||||||
const hasAdminAccess = accessMode === 'admin'
|
const FormActions = AdminFormActions
|
||||||
const adminLayout = layout === 'admin'
|
const FormSection = AdminFormSection
|
||||||
const FormActions = adminLayout ? AdminFormActions : ConsumerFormActions
|
|
||||||
const FormSection = adminLayout ? AdminFormSection : ConsumerFormSection
|
|
||||||
const { user } = useAuth()
|
|
||||||
const [event, setEvent] = useState<CreatedEvent | null>(null)
|
const [event, setEvent] = useState<CreatedEvent | null>(null)
|
||||||
const [categories, setCategories] = useState<EventCategory[]>([])
|
const [categories, setCategories] = useState<EventCategory[]>([])
|
||||||
const [cities, setCities] = useState<City[]>([])
|
const [cities, setCities] = useState<City[]>([])
|
||||||
@ -161,9 +149,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
const startDate = form.watch('startDate')
|
const startDate = form.watch('startDate')
|
||||||
const startTime = form.watch('startTime')
|
const startTime = form.watch('startTime')
|
||||||
const endDate = form.watch('endDate')
|
const endDate = form.watch('endDate')
|
||||||
const cityId = form.watch('cityId')
|
|
||||||
const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(startDate, startTime, endDate)
|
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(
|
const policy = useMemo(
|
||||||
() => (event ? resolveEventEditPolicy(event.status, event.bookedCount, event.reservedCapacity ?? 0) : null),
|
() => (event ? resolveEventEditPolicy(event.status, event.bookedCount, event.reservedCapacity ?? 0) : null),
|
||||||
@ -338,8 +324,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
const save = hasAdminAccess ? updateEventAsAdmin : updateEvent
|
const updated = await updateEventAsAdmin(eventId, payload)
|
||||||
const updated = await save(eventId, payload)
|
|
||||||
|
|
||||||
await syncEventMedia(eventId, initialMedia, media)
|
await syncEventMedia(eventId, initialMedia, media)
|
||||||
await syncEventFaqs(eventId, initialFaqs, faqs)
|
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>
|
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) {
|
if (!policy.canEdit) {
|
||||||
return (
|
return (
|
||||||
<div className="grid max-w-xl gap-3">
|
<div className="grid max-w-xl gap-3">
|
||||||
@ -389,7 +370,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<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)}
|
onSubmit={(e) => void handleSubmit(e)}
|
||||||
>
|
>
|
||||||
{policy.hasActiveBookings ? (
|
{policy.hasActiveBookings ? (
|
||||||
@ -400,7 +381,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
description={texts.events.basicInfoCardDescription}
|
description={texts.events.basicInfoCardDescription}
|
||||||
title={texts.events.basicInfoCardTitle}
|
title={texts.events.basicInfoCardTitle}
|
||||||
>
|
>
|
||||||
<WizardFormGrid singleColumn={layout === 'consumer'}>
|
<WizardFormGrid>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
generalType="input"
|
generalType="input"
|
||||||
@ -433,11 +414,10 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
/>
|
/>
|
||||||
</WizardFormFullWidth>
|
</WizardFormFullWidth>
|
||||||
<WizardFormFullWidth>
|
<WizardFormFullWidth>
|
||||||
{layout === 'consumer' ? null : <p className="labelClass mb-2">{texts.common.description}</p>}
|
<p className="labelClass mb-2">{texts.common.description}</p>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
label={layout === 'consumer' ? texts.common.description : undefined}
|
|
||||||
value={form.watch('description')}
|
value={form.watch('description')}
|
||||||
variant={layout === 'consumer' ? 'consumer' : 'default'}
|
variant="default"
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
form.setValue('description', value, { shouldDirty: true })
|
form.setValue('description', value, { shouldDirty: true })
|
||||||
}}
|
}}
|
||||||
@ -460,7 +440,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
description={texts.events.wizardStep2Hint}
|
description={texts.events.wizardStep2Hint}
|
||||||
title={texts.events.wizardStep2Label}
|
title={texts.events.wizardStep2Label}
|
||||||
>
|
>
|
||||||
<WizardFormGrid singleColumn={layout === 'consumer'}>
|
<WizardFormGrid>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
disabled={scheduleLocked}
|
disabled={scheduleLocked}
|
||||||
@ -493,7 +473,6 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
minValue={endMinimumTime}
|
minValue={endMinimumTime}
|
||||||
name="endTime"
|
name="endTime"
|
||||||
/>
|
/>
|
||||||
{adminLayout ? (
|
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
disabled={locationLocked}
|
disabled={locationLocked}
|
||||||
@ -504,14 +483,6 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
selectOptions={cities}
|
selectOptions={cities}
|
||||||
selectValue="name"
|
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>
|
<WizardFormFullWidth>
|
||||||
<Input
|
<Input
|
||||||
required
|
required
|
||||||
@ -566,7 +537,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
description={texts.events.wizardStep3Hint}
|
description={texts.events.wizardStep3Hint}
|
||||||
title={texts.events.pricingCardTitle}
|
title={texts.events.pricingCardTitle}
|
||||||
>
|
>
|
||||||
<WizardFormGrid singleColumn={layout === 'consumer'}>
|
<WizardFormGrid>
|
||||||
<div className="flex items-end pb-1">
|
<div className="flex items-end pb-1">
|
||||||
<Input
|
<Input
|
||||||
disabled={priceLocked}
|
disabled={priceLocked}
|
||||||
@ -684,7 +655,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<FormActions
|
<FormActions
|
||||||
className={adminLayout ? 'sticky bottom-3 z-20' : undefined}
|
className="sticky bottom-3 z-20"
|
||||||
helper={<UnsavedChangesIndicator isDirty={form.formState.isDirty} />}
|
helper={<UnsavedChangesIndicator isDirty={form.formState.isDirty} />}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -2,65 +2,40 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { coerceToString } from '@/helpers'
|
|
||||||
import { addToast } from '@/lib/toast'
|
|
||||||
import Button from '@/components/formElements/Button'
|
import Button from '@/components/formElements/Button'
|
||||||
import Input from '@/components/formElements/Input'
|
|
||||||
import AdminState from '@/components/feedback/AdminState'
|
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 ReviewCard from '@/components/reviews/ReviewCard'
|
||||||
import StatusChip from '@/components/ui/StatusChip'
|
import StatusChip from '@/components/ui/StatusChip'
|
||||||
import { getReviewStatus } from '@/constants/status'
|
import { getReviewStatus } from '@/constants/status'
|
||||||
import { isWithinHostReplyWindow } from '@/lib/reviewWindow'
|
|
||||||
import {
|
import {
|
||||||
EVENT_REVIEWS_PAGE_SIZE,
|
EVENT_REVIEWS_PAGE_SIZE,
|
||||||
HOST_REPLY_REVIEW,
|
|
||||||
LIST_ADMIN_EVENT_REVIEWS_PAGE,
|
LIST_ADMIN_EVENT_REVIEWS_PAGE,
|
||||||
LIST_EVENT_REVIEWS_PAGE,
|
type AdminEventReview,
|
||||||
type EventReview,
|
|
||||||
} from '@/services/reviews'
|
} from '@/services/reviews'
|
||||||
import { texts } from '@/texts'
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
interface EventReviewsPanelProps {
|
interface EventReviewsPanelProps {
|
||||||
eventId: string
|
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()
|
const name = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
return name || texts.events.guest
|
return name || texts.events.guest
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanelProps) => {
|
/** Read-only admin reviews for one event (includes hidden/deleted). */
|
||||||
const isAdmin = mode === 'admin'
|
const EventReviewsPanel = ({ eventId }: EventReviewsPanelProps) => {
|
||||||
const [reviews, setReviews] = useState<EventReview[]>([])
|
const [reviews, setReviews] = useState<AdminEventReview[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [totalItems, setTotalItems] = useState(0)
|
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(
|
const fetchPage = useCallback(
|
||||||
async (nextPage: number, options?: { errorMode: 'silent' }) => {
|
async (nextPage: number, options?: { errorMode: 'silent' }) =>
|
||||||
if (isAdmin) {
|
LIST_ADMIN_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options),
|
||||||
return LIST_ADMIN_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options)
|
[eventId]
|
||||||
}
|
|
||||||
|
|
||||||
return LIST_EVENT_REVIEWS_PAGE(eventId, nextPage, EVENT_REVIEWS_PAGE_SIZE, options)
|
|
||||||
},
|
|
||||||
[eventId, isAdmin]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
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))]
|
return [...current, ...result.data.items.filter((review) => !seen.has(review.id))]
|
||||||
})
|
})
|
||||||
setPage(nextPage)
|
|
||||||
}
|
}
|
||||||
|
setPage(nextPage)
|
||||||
setIsLoadingMore(false)
|
setIsLoadingMore(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,83 +71,27 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
|
|||||||
void load()
|
void load()
|
||||||
}, [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) {
|
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) {
|
if (reviews.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{isAdmin ? (
|
|
||||||
<AdminState
|
<AdminState
|
||||||
description={texts.reviews.hostEmptyDescription}
|
description={texts.reviews.hostEmptyDescription}
|
||||||
title={texts.reviews.hostEmptyTitle}
|
title={texts.reviews.hostEmptyTitle}
|
||||||
variant="empty"
|
variant="empty"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<ConsumerState
|
|
||||||
description={texts.reviews.hostEmptyDescription}
|
|
||||||
title={texts.reviews.hostEmptyTitle}
|
|
||||||
variant="empty"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 space-y-3">
|
<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">
|
<ul className="space-y-3">
|
||||||
{reviews.map((review) => {
|
{reviews.map((review) => {
|
||||||
const statusPresentation = isAdmin ? getReviewStatus(review.status) : null
|
const statusPresentation = getReviewStatus(review.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReviewCard
|
<ReviewCard
|
||||||
@ -180,33 +99,20 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
|
|||||||
authorAvatarUrl={review.user?.avatarUrl}
|
authorAvatarUrl={review.user?.avatarUrl}
|
||||||
authorName={authorDisplayName(review)}
|
authorName={authorDisplayName(review)}
|
||||||
authorUserId={review.user?.id}
|
authorUserId={review.user?.id}
|
||||||
body={review.body ?? (isAdmin ? texts.reviews.ratingOnly : null)}
|
body={review.body ?? texts.reviews.ratingOnly}
|
||||||
className={isAdmin ? 'admin-surface space-y-2 p-4' : 'consumer-surface space-y-2 p-4'}
|
className="admin-surface space-y-2 p-4"
|
||||||
createdAt={review.createdAt}
|
createdAt={review.createdAt}
|
||||||
footer={
|
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">
|
<div className="mt-3">
|
||||||
<StatusChip
|
<StatusChip
|
||||||
chipColor={statusPresentation.chipColor}
|
chipColor={statusPresentation.chipColor}
|
||||||
label={statusPresentation.label}
|
label={statusPresentation.label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
hostReplyBody={review.hostReplyBody}
|
hostReplyBody={review.hostReplyBody}
|
||||||
rating={review.rating}
|
rating={review.rating}
|
||||||
tone={isAdmin ? 'organizer' : 'consumer'}
|
tone="organizer"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -223,48 +129,6 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -428,7 +428,7 @@ const SERVICE_ERROR_MESSAGES: Record<string, string> = {
|
|||||||
* already narrow to — `(err as { response?: { data?: unknown } })?.response?.data` — so it
|
* already narrow to — `(err as { response?: { data?: unknown } })?.response?.data` — so it
|
||||||
* is a drop-in replacement for the hand-copied
|
* is a drop-in replacement for the hand-copied
|
||||||
* `extractServerErrorDetail((err as {...})?.response?.data)` block duplicated in
|
* `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
|
* `CreateSettlementModal.tsx`. Those call sites currently split the toast into a
|
||||||
* context-specific `title` plus a `description` (server detail, falling back to custom copy
|
* context-specific `title` plus a `description` (server detail, falling back to custom copy
|
||||||
* per call site); `getServiceErrorMessage`/`showServiceErrorToast` instead produce one
|
* per call site); `getServiceErrorMessage`/`showServiceErrorToast` instead produce one
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Query-key factory for admin dashboard data.
|
* 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}
|
* `list()` covers PaginatedList's generic {url + page/pageSize/sort/filters}
|
||||||
* shape. `listByUrl()` is the invalidation prefix: passing it to
|
* shape. `listByUrl()` is the invalidation prefix: passing it to
|
||||||
|
|||||||
17
queries/admin/useAdminCitiesQuery.ts
Normal file
17
queries/admin/useAdminCitiesQuery.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { adminKeys } from '@/queries/admin/adminKeys'
|
||||||
|
import { fetchAllCities } from '@/services/geography'
|
||||||
|
|
||||||
|
/** Cities for admin auth profile step. */
|
||||||
|
const CITIES_STALE_MS = 5 * 60_000
|
||||||
|
|
||||||
|
export const useAdminCitiesQuery = (enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: adminKeys.cities(),
|
||||||
|
enabled,
|
||||||
|
queryFn: () => fetchAllCities(),
|
||||||
|
staleTime: CITIES_STALE_MS,
|
||||||
|
})
|
||||||
@ -1,15 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import { consumerKeys } from '@/queries/consumerKeys'
|
|
||||||
import { discoveryReferenceStaleTime } from '@/services/discovery'
|
|
||||||
import { fetchAllCities } from '@/services/geography'
|
|
||||||
|
|
||||||
export const useDiscoveryCitiesQuery = (enabled = true) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: consumerKeys.discoveryCities(),
|
|
||||||
enabled,
|
|
||||||
queryFn: () => fetchAllCities(),
|
|
||||||
staleTime: discoveryReferenceStaleTime,
|
|
||||||
})
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import { consumerKeys } from '@/queries/consumerKeys'
|
|
||||||
import { unwrapService } from '@/queries/unwrapService'
|
|
||||||
import { LIST_PUBLIC_CATEGORIES } from '@/services/eventCategories'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public category taxonomy is admin-managed and changes rarely.
|
|
||||||
* Keep one shared staleTime here so every consumer of
|
|
||||||
* `consumerKeys.publicCategories()` agrees (wizard, step 1, browse).
|
|
||||||
*/
|
|
||||||
export const PUBLIC_CATEGORIES_STALE_TIME_MS = 5 * 60_000
|
|
||||||
|
|
||||||
export const usePublicCategoriesQuery = (enabled = true) =>
|
|
||||||
useQuery({
|
|
||||||
queryKey: consumerKeys.publicCategories(),
|
|
||||||
enabled,
|
|
||||||
queryFn: async () => unwrapService(await LIST_PUBLIC_CATEGORIES({ errorMode: 'parent' })),
|
|
||||||
staleTime: PUBLIC_CATEGORIES_STALE_TIME_MS,
|
|
||||||
})
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Query-key factory for shared cache keys still used by the admin app
|
|
||||||
* (auth me-cache, public categories, discovery cities for profile).
|
|
||||||
*/
|
|
||||||
export const consumerKeys = {
|
|
||||||
all: ['consumer'] as const,
|
|
||||||
|
|
||||||
me: () => [...consumerKeys.all, 'me'] as const,
|
|
||||||
discoveryCities: () => [...consumerKeys.all, 'discovery', 'cities'] as const,
|
|
||||||
publicCategories: () => [...consumerKeys.all, 'categories', 'public'] as const,
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { WalletTransaction } from '@/services/wallet'
|
import type { WalletTransactionResponseDto } from '@/api/generated/models'
|
||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
import { API_ROUTES } from '@/services/config'
|
import { API_ROUTES } from '@/services/config'
|
||||||
import {
|
import {
|
||||||
@ -12,6 +12,8 @@ import {
|
|||||||
} from '@/services/errorHandler'
|
} from '@/services/errorHandler'
|
||||||
import { unwrapApiData, type ApiResponse } from '@/services/apiResponse'
|
import { unwrapApiData, type ApiResponse } from '@/services/apiResponse'
|
||||||
|
|
||||||
|
export type WalletTransaction = WalletTransactionResponseDto
|
||||||
|
|
||||||
export interface AdminCreditWalletPayload {
|
export interface AdminCreditWalletPayload {
|
||||||
/** Amount in Toman (integer). */
|
/** Amount in Toman (integer). */
|
||||||
amount: number
|
amount: number
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
import { calculateCancellationPreview, resolveCancellationTicketPrice } from '@/services/bookings'
|
|
||||||
|
|
||||||
describe('booking cancellation preview', () => {
|
|
||||||
const startsAt = '2026-09-08T12:00:00.000Z'
|
|
||||||
const policy = {
|
|
||||||
cancellationFeePercent: 10,
|
|
||||||
cancellationFeePercent12To24Hours: 10,
|
|
||||||
cancellationFeePercentMoreThan24Hours: 10,
|
|
||||||
}
|
|
||||||
|
|
||||||
it('matches the backend floor-based commission formula', () => {
|
|
||||||
// 7% mirrors the platform default (backend/src/common/constants/business.constants.ts#COMMISSION_PERCENT);
|
|
||||||
// commissionPercent is always passed in from the booking's resolved rate, never hardcoded here.
|
|
||||||
expect(calculateCancellationPreview(101_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
|
|
||||||
ticketPrice: 101_000,
|
|
||||||
cancellationFeePercent: 10,
|
|
||||||
platformCommission: 707,
|
|
||||||
commissionPercent: 7,
|
|
||||||
hostCancellationFee: 9_393,
|
|
||||||
refundToGuest: 90_900,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not produce fractional Toman values', () => {
|
|
||||||
// A different rate here (5%) than the test above (7%) is deliberate — it
|
|
||||||
// exercises an event-level commission override, not just the platform default.
|
|
||||||
expect(
|
|
||||||
calculateCancellationPreview(999, { ...policy, cancellationFeePercent: 7 }, 5, startsAt, new Date('2026-09-08T11:00:00Z'))
|
|
||||||
).toEqual({
|
|
||||||
ticketPrice: 999,
|
|
||||||
cancellationFeePercent: 7,
|
|
||||||
platformCommission: 3,
|
|
||||||
commissionPercent: 5,
|
|
||||||
hostCancellationFee: 66,
|
|
||||||
refundToGuest: 930,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses post-discount paid amount so fees match refund.service gross', () => {
|
|
||||||
// List 10_000, discount 2_000 → paid 8_000; 10% penalty → refund 7_200.
|
|
||||||
expect(calculateCancellationPreview(8_000, policy, 7, startsAt, new Date('2026-09-08T11:00:00Z'))).toEqual({
|
|
||||||
ticketPrice: 8_000,
|
|
||||||
cancellationFeePercent: 10,
|
|
||||||
platformCommission: 56,
|
|
||||||
commissionPercent: 7,
|
|
||||||
hostCancellationFee: 744,
|
|
||||||
refundToGuest: 7_200,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves ticket price from payableAmount when a discount was applied', () => {
|
|
||||||
expect(resolveCancellationTicketPrice({ eventPrice: 10_000, payableAmount: 8_000 })).toBe(8_000)
|
|
||||||
expect(resolveCancellationTicketPrice({ eventPrice: 10_000, payableAmount: null })).toBe(10_000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,373 +0,0 @@
|
|||||||
import type { BookingResponseDto, WaitlistResponseDto, WaitlistStatus } from '@/api/generated/models'
|
|
||||||
import {
|
|
||||||
errorResult,
|
|
||||||
handleServiceError,
|
|
||||||
type ServiceCallOptions,
|
|
||||||
type ServiceResult,
|
|
||||||
shouldBubbleErrorToParent,
|
|
||||||
successResult,
|
|
||||||
} from '@/services/errorHandler'
|
|
||||||
import { unwrapApiData } from '@/services/apiResponse'
|
|
||||||
import { getBookings } from '@/api/generated/bookings/bookings'
|
|
||||||
import { getWaitlist } from '@/api/generated/waitlist/waitlist'
|
|
||||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
|
||||||
import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics'
|
|
||||||
import { getEventAttribution } from '@/lib/trafficAttribution'
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
// Shape mirrors the admin `bookingsColumns` fields used for the same
|
|
||||||
// underlying entity in app/(dashboard)/users/[id]/page.tsx (bookingCode,
|
|
||||||
// eventTitle, status, checkedInAt, createdAt) — plus `id`/`eventId`, which
|
|
||||||
// this self-service list needs for keys and the booking-detail modal.
|
|
||||||
// The `event*` fields come from bookingSelect's event relation
|
|
||||||
// (backend/src/modules/bookings/bookings.select.ts) — added alongside this
|
|
||||||
// service so the list + detail views don't need a second round-trip to
|
|
||||||
// /events/:id for title/price/fee.
|
|
||||||
export type MyBooking = BookingResponseDto
|
|
||||||
|
|
||||||
export type MyWaitlistEntry = WaitlistResponseDto
|
|
||||||
|
|
||||||
// Waitlist entries still considered "in progress" for this event — mirrors
|
|
||||||
// the DB constraint in waitlist.service.ts (assertNoActiveWaitlist).
|
|
||||||
const ACTIVE_WAITLIST_STATUSES: WaitlistStatus[] = ['waiting', 'notified', 'accepted']
|
|
||||||
const bookingsApi = getBookings()
|
|
||||||
const waitlistApi = getWaitlist()
|
|
||||||
|
|
||||||
export const GET_MY_BOOKINGS = async (options?: ServiceCallOptions): Promise<ServiceResult<MyBooking[]>> => {
|
|
||||||
try {
|
|
||||||
const res = await bookingsApi.bookingsControllerListMine({ page: 1, pageSize: 100 })
|
|
||||||
const payload = unwrapApiPayload(res.data)
|
|
||||||
const items = Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []
|
|
||||||
|
|
||||||
return successResult(items as MyBooking[])
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Guest's own active (pending_payment/confirmed) booking for one event, if any — event-booking.md entry checks. */
|
|
||||||
export const GET_MY_ACTIVE_BOOKING_FOR_EVENT = async (
|
|
||||||
eventId: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<MyBooking | null>> => {
|
|
||||||
try {
|
|
||||||
// At most one active booking per (user, event); list endpoint still
|
|
||||||
// requires pagination, so pageSize=1 is enough for this lookup.
|
|
||||||
const res = await bookingsApi.bookingsControllerListMine({
|
|
||||||
filters: { eventId },
|
|
||||||
pageSize: 1,
|
|
||||||
sort: '-createdAt',
|
|
||||||
})
|
|
||||||
const payload = unwrapApiPayload(res.data)
|
|
||||||
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyBooking[]
|
|
||||||
const latest = items[0]
|
|
||||||
const active = latest && (latest.status === 'pending_payment' || latest.status === 'confirmed') ? latest : null
|
|
||||||
|
|
||||||
return successResult(active)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Guest's own active waitlist entry for one event, if any — event-booking.md Path B. */
|
|
||||||
export const GET_MY_WAITLIST_ENTRY_FOR_EVENT = async (
|
|
||||||
eventId: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<MyWaitlistEntry | null>> => {
|
|
||||||
try {
|
|
||||||
// At most one active waitlist row per (user, event); pageSize=1 is enough.
|
|
||||||
const res = await waitlistApi.waitlistControllerListMine({
|
|
||||||
filters: { eventId },
|
|
||||||
pageSize: 1,
|
|
||||||
sort: '-createdAt',
|
|
||||||
})
|
|
||||||
const payload = unwrapApiPayload(res.data)
|
|
||||||
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[]
|
|
||||||
const latest = items[0]
|
|
||||||
const active = latest && ACTIVE_WAITLIST_STATUSES.includes(latest.status) ? latest : null
|
|
||||||
|
|
||||||
return successResult(active)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /events/:eventId/bookings — direct reserve path (event-booking.md, Path A). */
|
|
||||||
export const CREATE_BOOKING = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyBooking>> => {
|
|
||||||
try {
|
|
||||||
const attributionSource = getEventAttribution(eventId)
|
|
||||||
const res = await bookingsApi.bookingsControllerCreate(eventId, {
|
|
||||||
...(attributionSource ? { attributionSource } : {}),
|
|
||||||
})
|
|
||||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
||||||
|
|
||||||
if (data?.id) {
|
|
||||||
trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CREATED, data.id, {
|
|
||||||
booking_id: data.id,
|
|
||||||
event_id: eventId,
|
|
||||||
booking_status: data.status,
|
|
||||||
...(attributionSource ? { traffic_source: attributionSource } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
throw new Error(texts.bookings.emptyServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(data)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /events/:eventId/waitlist — join waitlist path (event-booking.md, Path B). */
|
|
||||||
export const JOIN_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyWaitlistEntry>> => {
|
|
||||||
try {
|
|
||||||
const attributionSource = getEventAttribution(eventId)
|
|
||||||
const res = await waitlistApi.waitlistControllerJoin(eventId, {
|
|
||||||
...(attributionSource ? { attributionSource } : {}),
|
|
||||||
})
|
|
||||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
throw new Error(texts.bookings.emptyServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(data)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /events/:eventId/waitlist — host-only view of the waiting queue for
|
|
||||||
* their own event, primarily used to hand-pick who gets an opened seat when
|
|
||||||
* `settings.waitlistAutoOffer` is `false`.
|
|
||||||
*/
|
|
||||||
export const GET_EVENT_WAITLIST = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<MyWaitlistEntry[]>> => {
|
|
||||||
try {
|
|
||||||
const res = await waitlistApi.waitlistControllerListForEvent(eventId, { pageSize: 100 })
|
|
||||||
const payload = unwrapApiPayload(res.data)
|
|
||||||
const items = (Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : []) as MyWaitlistEntry[]
|
|
||||||
|
|
||||||
return successResult(items)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /events/:eventId/waitlist/:entryId/offer — host manually offers a seat to a chosen entry. */
|
|
||||||
export const OFFER_WAITLIST_SEAT = async (
|
|
||||||
eventId: string,
|
|
||||||
entryId: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<MyWaitlistEntry>> => {
|
|
||||||
try {
|
|
||||||
const res = await waitlistApi.waitlistControllerOfferToEntry(eventId, entryId)
|
|
||||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
throw new Error(texts.bookings.emptyServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(data)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PATCH /waitlist/:id/accept — guest accepts a notified offer and receives the pending booking for checkout. */
|
|
||||||
export const ACCEPT_WAITLIST = async (
|
|
||||||
entryId: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<{ waitlistEntryId: string; booking: MyBooking }>> => {
|
|
||||||
try {
|
|
||||||
const res = await waitlistApi.waitlistControllerAccept(entryId)
|
|
||||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
||||||
|
|
||||||
if (!data?.booking) {
|
|
||||||
throw new Error(texts.bookings.emptyServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult({ waitlistEntryId: data.waitlistEntryId, booking: data.booking as MyBooking })
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /bookings/:id — single booking detail (owner only). */
|
|
||||||
export const GET_BOOKING = async (id: string, options?: ServiceCallOptions): Promise<ServiceResult<MyBooking>> => {
|
|
||||||
try {
|
|
||||||
const res = await bookingsApi.bookingsControllerFindOne(id)
|
|
||||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
throw new Error(texts.bookings.emptyServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(data)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PATCH /bookings/:id/cancel — guest-initiated cancel with mandatory reason (booking-cancellation-refund.md). */
|
|
||||||
export const CANCEL_BOOKING = async (
|
|
||||||
id: string,
|
|
||||||
cancellationReason: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<MyBooking>> => {
|
|
||||||
try {
|
|
||||||
const res = await bookingsApi.bookingsControllerCancel(id, { cancellationReason })
|
|
||||||
const data = res.data.success ? unwrapApiData(res.data) : undefined
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
throw new Error(texts.bookings.emptyServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
trackAnalyticsEventOnce(ANALYTICS_EVENTS.BOOKING_CANCELLED, data.id, {
|
|
||||||
booking_id: data.id,
|
|
||||||
event_id: data.eventId,
|
|
||||||
booking_status: data.status,
|
|
||||||
})
|
|
||||||
|
|
||||||
return successResult(data)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side guest-cancel refund preview — booking-cancellation-refund.md
|
|
||||||
* formula: refund = paidGross - selected cancellation fee. The platform
|
|
||||||
* commission is taken only from that fee and the remainder belongs to the
|
|
||||||
* host after the event completes.
|
|
||||||
* Mirrors refund.service.ts#refundGuestCancellation exactly (same
|
|
||||||
* floor() rounding on each component) so this preview never shows a
|
|
||||||
* different number than what's actually credited.
|
|
||||||
*
|
|
||||||
* `ticketPrice` must be the **amount actually paid** (`payments.total_amount`
|
|
||||||
* / `MyBooking.payableAmount`), not the list `eventPrice`. When a discount
|
|
||||||
* code was applied, those diverge — using list price overstates commission,
|
|
||||||
* host fee, and the toast refund. Callers: `payableAmount ?? eventPrice`.
|
|
||||||
*
|
|
||||||
* `commissionPercent` must be the booking's resolved rate
|
|
||||||
* (`MyBooking.eventEffectiveCommissionPercent` — event override if set, else
|
|
||||||
* the platform default; see bookings.service.ts#mapBooking on the backend)
|
|
||||||
* and NOT a locally hardcoded constant: the platform default has changed
|
|
||||||
* before (COMMISSION_PERCENT, backend/src/common/constants/business.constants.ts)
|
|
||||||
* and events can carry their own override, so any constant duplicated here
|
|
||||||
* silently drifts from what actually gets charged. Only meaningful for a
|
|
||||||
* `confirmed` (paid) booking; `pending_payment` has nothing to refund
|
|
||||||
* (never actually paid).
|
|
||||||
*/
|
|
||||||
export interface CancellationPreview {
|
|
||||||
ticketPrice: number
|
|
||||||
platformCommission: number
|
|
||||||
/** The rate actually applied, echoed back so callers can label the commission line without re-touching the booking. */
|
|
||||||
commissionPercent: number
|
|
||||||
cancellationFeePercent: number
|
|
||||||
hostCancellationFee: number
|
|
||||||
refundToGuest: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CancellationFeePolicy {
|
|
||||||
cancellationFeePercent: number
|
|
||||||
cancellationFeePercent12To24Hours: number
|
|
||||||
cancellationFeePercentMoreThan24Hours: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const HOUR_MS = 60 * 60 * 1000
|
|
||||||
|
|
||||||
export const resolveCancellationFeePercent = (
|
|
||||||
policy: CancellationFeePolicy,
|
|
||||||
eventStartsAt: string | Date,
|
|
||||||
cancelledAt: Date = new Date()
|
|
||||||
): number => {
|
|
||||||
const remainingMs = new Date(eventStartsAt).getTime() - cancelledAt.getTime()
|
|
||||||
|
|
||||||
if (remainingMs <= 12 * HOUR_MS) return policy.cancellationFeePercent
|
|
||||||
if (remainingMs <= 24 * HOUR_MS) return policy.cancellationFeePercent12To24Hours
|
|
||||||
|
|
||||||
return policy.cancellationFeePercentMoreThan24Hours
|
|
||||||
}
|
|
||||||
|
|
||||||
export const calculateCancellationPreview = (
|
|
||||||
ticketPrice: number,
|
|
||||||
policy: CancellationFeePolicy,
|
|
||||||
commissionPercent: number,
|
|
||||||
eventStartsAt: string | Date,
|
|
||||||
cancelledAt: Date = new Date()
|
|
||||||
): CancellationPreview => {
|
|
||||||
const cancellationFeePercent = resolveCancellationFeePercent(policy, eventStartsAt, cancelledAt)
|
|
||||||
const grossCancellationFee = Math.floor(ticketPrice * (cancellationFeePercent / 100))
|
|
||||||
const platformCommission = Math.floor(grossCancellationFee * (commissionPercent / 100))
|
|
||||||
const hostCancellationFee = grossCancellationFee - platformCommission
|
|
||||||
const refundToGuest = ticketPrice - grossCancellationFee
|
|
||||||
|
|
||||||
return { ticketPrice, platformCommission, commissionPercent, cancellationFeePercent, hostCancellationFee, refundToGuest }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gross base for guest-cancel preview — paid amount after discount, else list price. */
|
|
||||||
export const resolveCancellationTicketPrice = (booking: Pick<MyBooking, 'eventPrice' | 'payableAmount'>): number =>
|
|
||||||
booking.payableAmount ?? booking.eventPrice
|
|
||||||
@ -20,15 +20,8 @@ export const API_ROUTES = {
|
|||||||
ADMIN_REVISION_PENDING: (id: string) => `admin/events/${id}/revisions/pending`,
|
ADMIN_REVISION_PENDING: (id: string) => `admin/events/${id}/revisions/pending`,
|
||||||
ADMIN_REVISION_APPROVE: (id: string, revisionId: string) => `admin/events/${id}/revisions/${revisionId}/approve`,
|
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`,
|
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`,
|
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`,
|
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' },
|
EVENT_CATEGORIES: { ADMIN_FLAT: 'admin/event-categories/flat' },
|
||||||
GEOGRAPHY: { ALL_CITIES: 'geography/cities', PROVINCES: 'geography/provinces' },
|
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_REJECT: (id: string) => `admin/identity/verifications/${id}/reject`,
|
||||||
ADMIN_JIBIT_INQUIRY: (id: string) => `admin/identity/verifications/${id}/jibit-inquiry`,
|
ADMIN_JIBIT_INQUIRY: (id: string) => `admin/identity/verifications/${id}/jibit-inquiry`,
|
||||||
REJECTION_REASONS: 'identity/rejection-reasons',
|
REJECTION_REASONS: 'identity/rejection-reasons',
|
||||||
REQUEST_OTP: 'identity/verifications/request-otp',
|
|
||||||
CONFIRM_OTP: 'identity/verifications/confirm-otp',
|
|
||||||
},
|
},
|
||||||
PAYMENTS: { ADMIN_LIST: 'admin/payments' },
|
PAYMENTS: { ADMIN_LIST: 'admin/payments' },
|
||||||
WALLET_DEPOSITS: { ADMIN_LIST: 'admin/wallet-deposits' },
|
WALLET_DEPOSITS: { ADMIN_LIST: 'admin/wallet-deposits' },
|
||||||
@ -80,13 +71,9 @@ export const API_ROUTES = {
|
|||||||
ADMIN_BLOCK: (id: string) => `admin/user-reports/${id}/block`,
|
ADMIN_BLOCK: (id: string) => `admin/user-reports/${id}/block`,
|
||||||
},
|
},
|
||||||
CONTACT_MESSAGES: {
|
CONTACT_MESSAGES: {
|
||||||
CREATE: 'contact-messages',
|
|
||||||
ADMIN_LIST: 'admin/contact-messages',
|
ADMIN_LIST: 'admin/contact-messages',
|
||||||
ADMIN_READ: (id: string) => `admin/contact-messages/${id}/read`,
|
ADMIN_READ: (id: string) => `admin/contact-messages/${id}/read`,
|
||||||
},
|
},
|
||||||
TRAFFIC_VISITS: {
|
|
||||||
CREATE: 'traffic-visits',
|
|
||||||
},
|
|
||||||
SUPPORT_TICKETS: {
|
SUPPORT_TICKETS: {
|
||||||
ADMIN_LIST: 'admin/support-tickets',
|
ADMIN_LIST: 'admin/support-tickets',
|
||||||
ADMIN_DETAIL: (id: string) => `admin/support-tickets/${id}`,
|
ADMIN_DETAIL: (id: string) => `admin/support-tickets/${id}`,
|
||||||
|
|||||||
@ -2,12 +2,10 @@ import type {
|
|||||||
BulkCreateDiscountCodesResponseDto,
|
BulkCreateDiscountCodesResponseDto,
|
||||||
CreateDiscountCodesDto,
|
CreateDiscountCodesDto,
|
||||||
DiscountCodeResponseDto,
|
DiscountCodeResponseDto,
|
||||||
DiscountPreviewResponseDto,
|
|
||||||
DiscountRedemptionResponseDto,
|
DiscountRedemptionResponseDto,
|
||||||
DiscountReportSummaryDto,
|
DiscountReportSummaryDto,
|
||||||
} from '@/api/generated/models'
|
} from '@/api/generated/models'
|
||||||
import { getDiscountCodes } from '@/api/generated/discount-codes/discount-codes'
|
import { getDiscountCodes } from '@/api/generated/discount-codes/discount-codes'
|
||||||
import { getFinancial } from '@/api/generated/financial/financial'
|
|
||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
import { parseRemittanceList, unwrapApiPayload } from '@/helpers/listResponse'
|
import { parseRemittanceList, unwrapApiPayload } from '@/helpers/listResponse'
|
||||||
import { texts } from '@/texts'
|
import { texts } from '@/texts'
|
||||||
@ -25,7 +23,6 @@ export type DiscountCode = DiscountCodeResponseDto
|
|||||||
export type DiscountRedemption = DiscountRedemptionResponseDto
|
export type DiscountRedemption = DiscountRedemptionResponseDto
|
||||||
export type DiscountReport = DiscountReportSummaryDto
|
export type DiscountReport = DiscountReportSummaryDto
|
||||||
export type DiscountBulkResult = BulkCreateDiscountCodesResponseDto
|
export type DiscountBulkResult = BulkCreateDiscountCodesResponseDto
|
||||||
export type DiscountPreview = DiscountPreviewResponseDto
|
|
||||||
export type DiscountType = CreateDiscountCodesDto['type']
|
export type DiscountType = CreateDiscountCodesDto['type']
|
||||||
export type DiscountBearer = NonNullable<CreateDiscountCodesDto['bearer']>
|
export type DiscountBearer = NonNullable<CreateDiscountCodesDto['bearer']>
|
||||||
|
|
||||||
@ -59,7 +56,6 @@ export interface BulkCreateDiscountInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const discountApi = getDiscountCodes()
|
const discountApi = getDiscountCodes()
|
||||||
const financialApi = getFinancial()
|
|
||||||
|
|
||||||
const wrap = async <T>(run: () => Promise<T>, options?: ServiceCallOptions): Promise<ServiceResult<T>> => {
|
const wrap = async <T>(run: () => Promise<T>, options?: ServiceCallOptions): Promise<ServiceResult<T>> => {
|
||||||
try {
|
try {
|
||||||
@ -173,13 +169,3 @@ export const DELETE_DISCOUNT_CODE = (codeId: string, options?: ServiceCallOption
|
|||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
await discountApi.discountCodesControllerRemove(codeId)
|
await discountApi.discountCodesControllerRemove(codeId)
|
||||||
}, options)
|
}, options)
|
||||||
|
|
||||||
export const PREVIEW_DISCOUNT = (bookingId: string, code: string, options?: ServiceCallOptions): Promise<ServiceResult<DiscountPreview>> =>
|
|
||||||
wrap(async () => {
|
|
||||||
const res = await financialApi.paymentsControllerPreviewDiscount(bookingId, { code: code.trim() })
|
|
||||||
const data = unwrapApiPayload<DiscountPreview>(res.data)
|
|
||||||
|
|
||||||
if (!data?.code) throw createServiceError(texts.events.discountCodeInvalid)
|
|
||||||
|
|
||||||
return data
|
|
||||||
}, options)
|
|
||||||
|
|||||||
@ -1,230 +0,0 @@
|
|||||||
import type {
|
|
||||||
DiscoveryCategorySummaryDto,
|
|
||||||
HomeCategoryPreviewEventDto,
|
|
||||||
HomeFeedResponseDto,
|
|
||||||
HomePopularEventDto,
|
|
||||||
} from '@/api/generated/models'
|
|
||||||
import { getEvents } from '@/api/generated/events/events'
|
|
||||||
import { DISCOVERY_PAGE_SIZE } from '@/constants/discovery'
|
|
||||||
import axiosInstance from '@/config/axios'
|
|
||||||
import type { City } from '@/services/geography'
|
|
||||||
import {
|
|
||||||
errorResult,
|
|
||||||
handleServiceError,
|
|
||||||
type ServiceCallOptions,
|
|
||||||
type ServiceResult,
|
|
||||||
shouldBubbleErrorToParent,
|
|
||||||
successResult,
|
|
||||||
} from '@/services/errorHandler'
|
|
||||||
import { getEventCategories } from '@/api/generated/event-categories/event-categories'
|
|
||||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
|
||||||
import type { EventStatus } from '@/types/status'
|
|
||||||
|
|
||||||
/** Lightweight host name on public discovery cards. */
|
|
||||||
export interface DiscoveryEventOrganizer {
|
|
||||||
firstName: string | null
|
|
||||||
lastName: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public discovery event shape (GET /events).
|
|
||||||
* Hand-written so type-aware ESLint resolves it while `api/generated` stays eslint-ignored.
|
|
||||||
*/
|
|
||||||
export interface DiscoveryEvent {
|
|
||||||
id: string
|
|
||||||
organizerId: string
|
|
||||||
categoryId: number
|
|
||||||
clonedFromEventId: string | null
|
|
||||||
title: string
|
|
||||||
slug: string
|
|
||||||
shortDescription: string | null
|
|
||||||
description: string | null
|
|
||||||
startsAt: string
|
|
||||||
endsAt: string
|
|
||||||
provinceId: number
|
|
||||||
cityId: number
|
|
||||||
address: string | null
|
|
||||||
lat: number | null
|
|
||||||
lng: number | null
|
|
||||||
isFree: boolean
|
|
||||||
price: number
|
|
||||||
capacity: number
|
|
||||||
bookedCount: number
|
|
||||||
reservedCapacity: number
|
|
||||||
cancellationFeePercent: number
|
|
||||||
cancellationFeePercent12To24Hours: number
|
|
||||||
cancellationFeePercentMoreThan24Hours: number
|
|
||||||
commissionPercent: number | null
|
|
||||||
effectiveCommissionPercent: number
|
|
||||||
status: EventStatus
|
|
||||||
isFeatured: boolean
|
|
||||||
publishedAt: string | null
|
|
||||||
adminApprovedAt: string | null
|
|
||||||
rejectionReason: string | null
|
|
||||||
avgRating?: number | null
|
|
||||||
reviewsCount: number
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
posterUrl: string | null
|
|
||||||
squarePosterUrl: string | null
|
|
||||||
organizer: DiscoveryEventOrganizer
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DiscoveryCategory = DiscoveryCategorySummaryDto
|
|
||||||
export type HomePopularEvent = HomePopularEventDto
|
|
||||||
export type HomeCategoryPreviewEvent = HomeCategoryPreviewEventDto
|
|
||||||
export type HomeFeed = HomeFeedResponseDto
|
|
||||||
|
|
||||||
export interface DiscoveryFilters {
|
|
||||||
categoryIds?: number[]
|
|
||||||
cityId?: number
|
|
||||||
provinceId?: number
|
|
||||||
startsAtFrom?: string
|
|
||||||
startsAtTo?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscoveryListResult {
|
|
||||||
items: DiscoveryEvent[]
|
|
||||||
totalItemsCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscoveryPagination {
|
|
||||||
page?: number
|
|
||||||
pageSize?: number
|
|
||||||
sort?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HomeFeedQuery {
|
|
||||||
cityId?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscoveryBootstrap {
|
|
||||||
categories: DiscoveryCategory[]
|
|
||||||
cities: City[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventsApi = getEvents()
|
|
||||||
const eventCategoriesApi = getEventCategories()
|
|
||||||
|
|
||||||
// Cities and categories are shared public reference data, but are still
|
|
||||||
// admin-managed. A finite window lets long-lived tabs see taxonomy changes
|
|
||||||
// after focus/reconnect instead of keeping them stale until a deploy.
|
|
||||||
const REFERENCE_STALE_MS = 5 * 60_000
|
|
||||||
const HOME_FEED_STALE_MS = 60_000
|
|
||||||
|
|
||||||
export const discoveryReferenceStaleTime = REFERENCE_STALE_MS
|
|
||||||
export const discoveryHomeFeedStaleTime = HOME_FEED_STALE_MS
|
|
||||||
|
|
||||||
export function homeCategoryPreviewMap(feed: HomeFeed | undefined): Map<number, HomeCategoryPreviewEvent[]> {
|
|
||||||
const map = new Map<number, HomeCategoryPreviewEvent[]>()
|
|
||||||
|
|
||||||
for (const group of feed?.categoryPreviews ?? []) {
|
|
||||||
map.set(group.categoryId, group.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDiscoveryCities(): Promise<City[]> {
|
|
||||||
const response = await eventsApi.discoveryBootstrapControllerListCities()
|
|
||||||
const payload = unwrapApiPayload<City[]>(response.data)
|
|
||||||
|
|
||||||
return payload ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDiscoveryCategories(): Promise<DiscoveryCategory[]> {
|
|
||||||
const response = await eventsApi.discoveryBootstrapControllerListCategories()
|
|
||||||
const payload = unwrapApiPayload<DiscoveryCategory[]>(response.data)
|
|
||||||
|
|
||||||
return payload ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchHomeFeed(query: HomeFeedQuery = {}, options?: ServiceCallOptions): Promise<ServiceResult<HomeFeed>> {
|
|
||||||
try {
|
|
||||||
const response = await eventsApi.discoveryBootstrapControllerGetHomeFeed({
|
|
||||||
cityId: query.cityId,
|
|
||||||
})
|
|
||||||
const payload = unwrapApiPayload<HomeFeed>(response.data)
|
|
||||||
|
|
||||||
if (!payload) {
|
|
||||||
throw new Error('Home feed fetch returned no data')
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(payload)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @deprecated Prefer `fetchDiscoveryCities` + `fetchDiscoveryCategories`. */
|
|
||||||
export async function fetchDiscoveryBootstrap(): Promise<DiscoveryBootstrap> {
|
|
||||||
const response = await axiosInstance.get('discovery/bootstrap')
|
|
||||||
const payload = unwrapApiPayload<DiscoveryBootstrap>(response.data)
|
|
||||||
|
|
||||||
return payload ?? { categories: [], cities: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full category list via event-categories module (admin-adjacent surfaces). */
|
|
||||||
export async function fetchEventCategoriesList(options?: ServiceCallOptions): Promise<ServiceResult<DiscoveryCategory[]>> {
|
|
||||||
try {
|
|
||||||
const response = await eventCategoriesApi.eventCategoriesControllerList()
|
|
||||||
const payload = unwrapApiPayload(response.data)
|
|
||||||
|
|
||||||
return successResult(Array.isArray(payload) ? (payload as DiscoveryCategory[]) : [])
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export async function fetchDiscoveryEvents(
|
|
||||||
filters: DiscoveryFilters,
|
|
||||||
paginationOptions: DiscoveryPagination = {},
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<DiscoveryListResult>> {
|
|
||||||
try {
|
|
||||||
const params: {
|
|
||||||
page: number
|
|
||||||
pageSize: number
|
|
||||||
sort?: string
|
|
||||||
filters: Record<string, string>
|
|
||||||
} = {
|
|
||||||
page: paginationOptions.page ?? 1,
|
|
||||||
pageSize: paginationOptions.pageSize ?? DISCOVERY_PAGE_SIZE,
|
|
||||||
filters: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
if (paginationOptions.sort) params.sort = paginationOptions.sort
|
|
||||||
if (filters.categoryIds?.length) params.filters.categoryId = filters.categoryIds.join(',')
|
|
||||||
if (filters.cityId !== undefined) params.filters.cityId = String(filters.cityId)
|
|
||||||
if (filters.provinceId !== undefined) params.filters.provinceId = String(filters.provinceId)
|
|
||||||
if (filters.startsAtFrom) params.filters.startsAtFrom = filters.startsAtFrom
|
|
||||||
if (filters.startsAtTo) params.filters.startsAtTo = filters.startsAtTo
|
|
||||||
|
|
||||||
const response = await eventsApi.eventsControllerDiscovery(params)
|
|
||||||
const body = response.data as { data?: { items?: DiscoveryEvent[]; response?: { totalItemsCount?: number } } }
|
|
||||||
const data = body.data ?? {}
|
|
||||||
const items = Array.isArray(data.items) ? data.items : []
|
|
||||||
const totalItemsCount = data.response?.totalItemsCount ?? items.length
|
|
||||||
|
|
||||||
return successResult({ items, totalItemsCount })
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { CreateEventCategoryDto, EventCategoryResponseDto } from '@/api/generated/models'
|
import type { CreateEventCategoryDto, EventCategoryResponseDto, EventCategorySummaryResponseDto } from '@/api/generated/models'
|
||||||
import {
|
import {
|
||||||
createServiceError,
|
createServiceError,
|
||||||
errorResult,
|
errorResult,
|
||||||
@ -10,26 +10,20 @@ import {
|
|||||||
} from '@/services/errorHandler'
|
} from '@/services/errorHandler'
|
||||||
import { unwrapApiData } from '@/services/apiResponse'
|
import { unwrapApiData } from '@/services/apiResponse'
|
||||||
import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories'
|
import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories'
|
||||||
import { getEventCategories } from '@/api/generated/event-categories/event-categories'
|
|
||||||
import { texts } from '@/texts'
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
export type EventCategory = EventCategoryResponseDto
|
export type EventCategory = EventCategoryResponseDto
|
||||||
|
export type EventCategorySummary = EventCategorySummaryResponseDto
|
||||||
export type EventCategoryPayload = CreateEventCategoryDto
|
export type EventCategoryPayload = CreateEventCategoryDto
|
||||||
|
|
||||||
const eventCategoriesApi = getEventCategories()
|
|
||||||
const adminEventCategoriesApi = getAdminEventCategories()
|
const adminEventCategoriesApi = getAdminEventCategories()
|
||||||
|
|
||||||
/**
|
/** Flat admin category list for filters/selects (id + name). */
|
||||||
* Public, unpaginated flat list of *active* categories — `GET /event-categories`
|
export const LIST_ADMIN_CATEGORIES_FLAT = async (
|
||||||
* (see backend/src/modules/event-categories/event-categories.controller.ts).
|
options?: ServiceCallOptions
|
||||||
* Used by the consumer app's categories tab; unlike GET_CATEGORY_CHILDREN
|
): Promise<ServiceResult<{ items: EventCategorySummary[] }>> => {
|
||||||
* 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[] }>> => {
|
|
||||||
try {
|
try {
|
||||||
const res = await eventCategoriesApi.eventCategoriesControllerList()
|
const res = await adminEventCategoriesApi.adminEventCategoriesControllerListFlat()
|
||||||
const payload = res.data
|
const payload = res.data
|
||||||
|
|
||||||
if (!payload.success) {
|
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`
|
* One "level" of the category tree: every direct child of `parentId`
|
||||||
* (or every root category, when `parentId` is null). Page size is fixed
|
* (or every root category, when `parentId` is null).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export const GET_CATEGORY_CHILDREN = async (
|
export const GET_CATEGORY_CHILDREN = async (
|
||||||
parentId: number | null,
|
parentId: number | null,
|
||||||
|
|||||||
@ -9,8 +9,8 @@ export type EventFaq = EventFaqResponseDto
|
|||||||
const eventExtrasApi = getEventExtras()
|
const eventExtrasApi = getEventExtras()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /events/:eventId/media` — public, unpaginated, ordered by `sortOrder` asc.
|
* Shared public list endpoints — backend allows admin reads for event detail/edit.
|
||||||
* See docs/workflows/fa/event-discovery.md, stage 3.
|
* No dedicated admin media/FAQ list routes exist in OpenAPI.
|
||||||
*/
|
*/
|
||||||
export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> {
|
export async function fetchEventMedia(eventId: string): Promise<EventMedia[]> {
|
||||||
const response = await eventExtrasApi.eventMediaControllerList(eventId)
|
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[]) : []
|
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[]> {
|
export async function fetchEventFaqs(eventId: string): Promise<EventFaq[]> {
|
||||||
const response = await eventExtrasApi.eventFaqsControllerList(eventId)
|
const response = await eventExtrasApi.eventFaqsControllerList(eventId)
|
||||||
const payload = unwrapApiPayload(response.data)
|
const payload = unwrapApiPayload(response.data)
|
||||||
|
|||||||
@ -1,36 +1,22 @@
|
|||||||
import type { GuestListLinkResponseDto, InvitedGuestResponseDto } from '@/api/generated/models'
|
import type { GuestListLinkResponseDto, InvitedGuestResponseDto } from '@/api/generated/models'
|
||||||
import { getAdminEventExtras } from '@/api/generated/admin-event-extras/admin-event-extras'
|
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'
|
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||||
|
|
||||||
export type EventGuestListAccessMode = 'admin' | 'owner'
|
|
||||||
export type EventGuestListLink = GuestListLinkResponseDto
|
export type EventGuestListLink = GuestListLinkResponseDto
|
||||||
export type EventInvitedGuest = InvitedGuestResponseDto
|
export type EventInvitedGuest = InvitedGuestResponseDto
|
||||||
|
|
||||||
const adminEventExtrasApi = getAdminEventExtras()
|
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)
|
const payload = unwrapApiPayload<EventGuestListLink[]>(response.data)
|
||||||
|
|
||||||
return Array.isArray(payload) ? payload : []
|
return Array.isArray(payload) ? payload : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEventInvitedGuests(eventId: string, accessMode: EventGuestListAccessMode): Promise<EventInvitedGuest[]> {
|
export async function fetchEventInvitedGuests(eventId: string): Promise<EventInvitedGuest[]> {
|
||||||
if (accessMode === 'admin') {
|
|
||||||
const response = await adminEventExtrasApi.adminEventInvitedGuestsControllerGet(eventId)
|
const response = await adminEventExtrasApi.adminEventInvitedGuestsControllerGet(eventId)
|
||||||
const payload = unwrapApiPayload<EventInvitedGuest[]>(response.data)
|
const payload = unwrapApiPayload<EventInvitedGuest[]>(response.data)
|
||||||
|
|
||||||
return Array.isArray(payload) ? payload : []
|
return Array.isArray(payload) ? payload : []
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await eventExtrasApi.eventGuestListLinksControllerGetInvitedGuests(eventId)
|
|
||||||
const payload = unwrapApiPayload<EventInvitedGuest[]>(response.data)
|
|
||||||
|
|
||||||
return Array.isArray(payload) ? payload : []
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,25 +1,7 @@
|
|||||||
import type {
|
import type { OwnerEventInsightsResponseDto } from '@/api/generated/models'
|
||||||
EventAttendeeFilter,
|
|
||||||
EventAttendeeResponseDto,
|
|
||||||
MyEventsControllerAttendees200,
|
|
||||||
OwnerEventInsightsResponseDto,
|
|
||||||
} from '@/api/generated/models'
|
|
||||||
import { getAdminBookings } from '@/api/generated/admin-bookings/admin-bookings'
|
import { getAdminBookings } from '@/api/generated/admin-bookings/admin-bookings'
|
||||||
import { getAdminEvents } from '@/api/generated/admin-events/admin-events'
|
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 { 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. */
|
/** Extended until OpenAPI client is regenerated with traffic insights. */
|
||||||
export type EventManagementInsights = OwnerEventInsightsResponseDto & {
|
export type EventManagementInsights = OwnerEventInsightsResponseDto & {
|
||||||
@ -28,41 +10,10 @@ export type EventManagementInsights = OwnerEventInsightsResponseDto & {
|
|||||||
telegram: { uniqueVisits: number; confirmedBookings: number }
|
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 adminEventsApi = getAdminEvents()
|
||||||
const adminBookingsApi = getAdminBookings()
|
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). */
|
/** Admin can view insights for any organizer's event (no ownership check). */
|
||||||
export async function fetchEventInsightsAsAdmin(eventId: string): Promise<EventManagementInsights> {
|
export async function fetchEventInsightsAsAdmin(eventId: string): Promise<EventManagementInsights> {
|
||||||
const response = await adminEventsApi.adminEventsControllerInsights(eventId)
|
const response = await adminEventsApi.adminEventsControllerInsights(eventId)
|
||||||
@ -70,35 +21,6 @@ export async function fetchEventInsightsAsAdmin(eventId: string): Promise<EventM
|
|||||||
return unwrapApiPayload<EventManagementInsights>(response.data)
|
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). */
|
/** Admin can check in a guest for any organizer's event (no ownership check). */
|
||||||
export async function checkInBookingAsAdmin(bookingId: string): Promise<void> {
|
export async function checkInBookingAsAdmin(bookingId: string): Promise<void> {
|
||||||
await adminBookingsApi.adminBookingsControllerCheckIn(bookingId)
|
await adminBookingsApi.adminBookingsControllerCheckIn(bookingId)
|
||||||
|
|||||||
@ -4,112 +4,45 @@ import type {
|
|||||||
CreateEventMediaDto,
|
CreateEventMediaDto,
|
||||||
EventCategoryResponseDto,
|
EventCategoryResponseDto,
|
||||||
EventResponseDto,
|
EventResponseDto,
|
||||||
OrganizerGuestListResponseDto,
|
|
||||||
} from '@/api/generated/models'
|
} 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 { 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 { getEventExtras } from '@/api/generated/event-extras/event-extras'
|
||||||
import { getMyEvents } from '@/api/generated/my-events/my-events'
|
|
||||||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
import { API_ROUTES } from '@/services/config'
|
import { API_ROUTES } from '@/services/config'
|
||||||
import { texts } from '@/texts'
|
|
||||||
import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics'
|
|
||||||
|
|
||||||
export type EventCategory = EventCategoryResponseDto
|
export type EventCategory = EventCategoryResponseDto
|
||||||
export type CreateEventPayload = CreateEventDto
|
export type CreateEventPayload = CreateEventDto
|
||||||
export type CreatedEvent = EventResponseDto
|
export type CreatedEvent = EventResponseDto
|
||||||
export type CreateEventMediaPayload = CreateEventMediaDto
|
export type CreateEventMediaPayload = CreateEventMediaDto
|
||||||
export type CreateEventFaqPayload = CreateEventFaqDto
|
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 adminEventsApi = getAdminEvents()
|
||||||
const eventCategoriesApi = getEventCategories()
|
const adminEventCategoriesApi = getAdminEventCategories()
|
||||||
const eventExtrasApi = getEventExtras()
|
const eventExtrasApi = getEventExtras()
|
||||||
|
|
||||||
|
/** Full category tree for admin edit forms — admin list, not public discovery. */
|
||||||
export async function fetchEventCategories(): Promise<EventCategory[]> {
|
export async function fetchEventCategories(): Promise<EventCategory[]> {
|
||||||
const response = await eventCategoriesApi.eventCategoriesControllerList()
|
const items: EventCategory[] = []
|
||||||
const payload = unwrapApiPayload(response.data)
|
let page = 1
|
||||||
|
let totalPages = 1
|
||||||
|
|
||||||
return Array.isArray(payload) ? (payload as EventCategory[]) : []
|
while (page <= totalPages) {
|
||||||
}
|
const response = await adminEventCategoriesApi.adminEventCategoriesControllerList({
|
||||||
|
page,
|
||||||
export async function createEvent(payload: CreateEventPayload): Promise<CreatedEvent> {
|
pageSize: 100,
|
||||||
const response = await myEventsApi.myEventsControllerCreate(payload)
|
sort: 'sortOrder',
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
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-
|
return items
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin approval is one half of the two-key publication workflow. */
|
/** 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)
|
return unwrapApiPayload<CreatedEvent>(response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventEditAccessMode = 'admin' | 'owner'
|
export async function updateEventReservedCapacity(eventId: string, reservedCapacity: number): Promise<CreatedEvent> {
|
||||||
|
const response = await axiosInstance.patch(API_ROUTES.EVENTS.ADMIN_RESERVED_CAPACITY(eventId), { reservedCapacity })
|
||||||
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 })
|
|
||||||
|
|
||||||
return unwrapApiPayload<CreatedEvent>(response.data)
|
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;
|
* Full event for edit forms. Backend authorizes admin (and owner);
|
||||||
* callers do not pass access mode.
|
* admin panel uses this for the edit screen.
|
||||||
*/
|
*/
|
||||||
export async function fetchEventForEdit(eventId: string): Promise<CreatedEvent> {
|
export async function fetchEventForEdit(eventId: string): Promise<CreatedEvent> {
|
||||||
const response = await axiosInstance.get(API_ROUTES.EVENTS.FOR_EDIT(eventId))
|
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)
|
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> {
|
export async function createEventMedia(eventId: string, payload: CreateEventMediaPayload): Promise<void> {
|
||||||
await eventExtrasApi.eventMediaControllerCreate(eventId, payload)
|
await eventExtrasApi.eventMediaControllerCreate(eventId, payload)
|
||||||
}
|
}
|
||||||
@ -262,29 +186,6 @@ export interface EventRevision {
|
|||||||
decidedAt: string | null
|
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> {
|
export async function fetchAdminPendingEventRevision(eventId: string): Promise<EventRevision | null> {
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get(API_ROUTES.EVENTS.ADMIN_REVISION_PENDING(eventId))
|
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)
|
return unwrapApiPayload<EventRevision>(response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function linkGuestListToEvent(eventId: string, listId: string): Promise<void> {
|
|
||||||
await eventExtrasApi.eventGuestListLinksControllerLink(eventId, { listId })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchOrganizerGuestLists(): Promise<OrganizerGuestList[]> {
|
|
||||||
const response = await eventExtrasApi.organizerGuestListsControllerList({ page: 1, pageSize: 100 })
|
|
||||||
const payload = unwrapApiPayload(response.data)
|
|
||||||
const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload) ? payload : []
|
|
||||||
|
|
||||||
return items as OrganizerGuestList[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPreviousAttendees(): Promise<PreviousAttendee[]> {
|
|
||||||
const response = await axiosInstance.get('/organizer-guest-lists/previous-attendees')
|
|
||||||
const payload = unwrapApiPayload(response.data)
|
|
||||||
|
|
||||||
return Array.isArray(payload) ? (payload as PreviousAttendee[]) : []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setPreviousAttendeeGuests(eventId: string, userIds: string[]): Promise<void> {
|
|
||||||
// Persists the invite set. SMS is sent automatically when the event is published
|
|
||||||
// (or immediately if it is already published and has not been notified yet).
|
|
||||||
await axiosInstance.put(`/events/${eventId}/guest-list-links/invited-guests`, { userIds })
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import type { PushSubscriptionResponseDto, PushVapidPublicKeyResponseDto } from '@/api/generated/models'
|
|
||||||
import {
|
import {
|
||||||
createServiceError,
|
|
||||||
errorResult,
|
errorResult,
|
||||||
handleServiceError,
|
handleServiceError,
|
||||||
type ServiceCallOptions,
|
type ServiceCallOptions,
|
||||||
@ -8,65 +6,12 @@ import {
|
|||||||
shouldBubbleErrorToParent,
|
shouldBubbleErrorToParent,
|
||||||
successResult,
|
successResult,
|
||||||
} from '@/services/errorHandler'
|
} from '@/services/errorHandler'
|
||||||
import { unwrapApiData } from '@/services/apiResponse'
|
|
||||||
import { getNotifications } from '@/api/generated/notifications/notifications'
|
import { getNotifications } from '@/api/generated/notifications/notifications'
|
||||||
import { clearStoredPushSubscriptionId, getStoredPushSubscriptionId, unsubscribeBrowserPushManager } from '@/lib/pwa'
|
import { clearStoredPushSubscriptionId, getStoredPushSubscriptionId, unsubscribeBrowserPushManager } from '@/lib/pwa'
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
export type PushSubscriptionRecord = PushSubscriptionResponseDto
|
|
||||||
export type VapidPublicKeyResult = PushVapidPublicKeyResponseDto
|
|
||||||
|
|
||||||
const notificationsApi = getNotifications()
|
const notificationsApi = getNotifications()
|
||||||
|
|
||||||
export const GET_VAPID_PUBLIC_KEY = async (options?: ServiceCallOptions): Promise<ServiceResult<VapidPublicKeyResult>> => {
|
const UNSUBSCRIBE_PUSH = async (
|
||||||
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 (
|
|
||||||
subscriptionId: string,
|
subscriptionId: string,
|
||||||
options?: ServiceCallOptions
|
options?: ServiceCallOptions
|
||||||
): Promise<ServiceResult<{ deleted: boolean }>> => {
|
): 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 }>> => {
|
/** Best-effort cleanup on admin logout — no subscribe flow in this app. */
|
||||||
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.
|
|
||||||
*/
|
|
||||||
export const unsubscribeCurrentDevicePush = async (): Promise<void> => {
|
export const unsubscribeCurrentDevicePush = async (): Promise<void> => {
|
||||||
const subscriptionId = getStoredPushSubscriptionId()
|
const subscriptionId = getStoredPushSubscriptionId()
|
||||||
|
|
||||||
@ -119,7 +39,7 @@ export const unsubscribeCurrentDevicePush = async (): Promise<void> => {
|
|||||||
await UNSUBSCRIBE_PUSH(subscriptionId, { errorMode: 'silent' })
|
await UNSUBSCRIBE_PUSH(subscriptionId, { errorMode: 'silent' })
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await unsubscribeBrowserPushManager()
|
|
||||||
clearStoredPushSubscriptionId()
|
clearStoredPushSubscriptionId()
|
||||||
|
await unsubscribeBrowserPushManager().catch(() => undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,5 @@
|
|||||||
import type {
|
import type { AdminReviewResponseDto } from '@/api/generated/models'
|
||||||
AdminReviewResponseDto,
|
|
||||||
CreateReviewDto,
|
|
||||||
HostReplyDto,
|
|
||||||
OrganizerReviewResponseDto,
|
|
||||||
ReviewResponseDto,
|
|
||||||
UpdateReviewDto,
|
|
||||||
} from '@/api/generated/models'
|
|
||||||
import { getAdminReviews } from '@/api/generated/admin-reviews/admin-reviews'
|
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 { parseRemittanceList } from '@/helpers/listResponse'
|
||||||
import {
|
import {
|
||||||
errorResult,
|
errorResult,
|
||||||
@ -19,58 +10,11 @@ import {
|
|||||||
successResult,
|
successResult,
|
||||||
} from '@/services/errorHandler'
|
} from '@/services/errorHandler'
|
||||||
|
|
||||||
export type EventReview = ReviewResponseDto
|
|
||||||
export type AdminEventReview = AdminReviewResponseDto
|
export type AdminEventReview = AdminReviewResponseDto
|
||||||
export type OrganizerReview = OrganizerReviewResponseDto
|
|
||||||
|
|
||||||
const reviewsApi = getReviews()
|
|
||||||
const adminReviewsApi = getAdminReviews()
|
const adminReviewsApi = getAdminReviews()
|
||||||
|
|
||||||
export const EVENT_REVIEWS_PAGE_SIZE = 10
|
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. */
|
/** Admin list for one event — includes published, hidden, and deleted reviews. */
|
||||||
export const LIST_ADMIN_EVENT_REVIEWS_PAGE = async (
|
export const LIST_ADMIN_EVENT_REVIEWS_PAGE = async (
|
||||||
@ -102,126 +46,3 @@ export const LIST_ADMIN_EVENT_REVIEWS_PAGE = async (
|
|||||||
return errorResult(normalizedError)
|
return errorResult(normalizedError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LIST_EVENT_REVIEWS = async (eventId: string, options?: ServiceCallOptions): Promise<ServiceResult<EventReview[]>> => {
|
|
||||||
const result = await LIST_EVENT_REVIEWS_PAGE(eventId, 1, 50, options)
|
|
||||||
|
|
||||||
return result.ok ? successResult(result.data.items) : result
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LIST_ORGANIZER_REVIEWS = async (
|
|
||||||
organizerId: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<OrganizerReview[]>> => {
|
|
||||||
try {
|
|
||||||
const res = await reviewsApi.reviewsControllerListByOrganizer(organizerId, {
|
|
||||||
sort: '-createdAt',
|
|
||||||
page: 1,
|
|
||||||
pageSize: 50,
|
|
||||||
})
|
|
||||||
|
|
||||||
return successResult(unwrapItems<OrganizerReview>(unwrapApiPayload(res.data)))
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LIST_ORGANIZER_REVIEWS_PAGE = async (
|
|
||||||
organizerId: string,
|
|
||||||
page = 1,
|
|
||||||
pageSize = ORGANIZER_REVIEWS_PAGE_SIZE,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<{ items: OrganizerReview[]; page: number; totalItemsCount: number; totalPages: number }>> => {
|
|
||||||
try {
|
|
||||||
const res = await reviewsApi.reviewsControllerListByOrganizer(organizerId, {
|
|
||||||
sort: '-createdAt',
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
})
|
|
||||||
const parsed = parseRemittanceList<OrganizerReview>(res.data, 'items', page, pageSize)
|
|
||||||
|
|
||||||
return successResult({
|
|
||||||
items: parsed.items,
|
|
||||||
page: parsed.pagination.page,
|
|
||||||
totalItemsCount: parsed.pagination.totalItemsCount,
|
|
||||||
totalPages: parsed.pagination.totalPages ?? 0,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Published review by this user for the event, if any (from the public event list). */
|
|
||||||
export const FIND_MY_EVENT_REVIEW = async (
|
|
||||||
eventId: string,
|
|
||||||
userId: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<EventReview | null>> => {
|
|
||||||
const result = await LIST_EVENT_REVIEWS(eventId, options)
|
|
||||||
|
|
||||||
if (!result.ok) return result
|
|
||||||
|
|
||||||
return successResult(result.data.find((review) => review.userId === userId) ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CREATE_REVIEW = async (
|
|
||||||
eventId: string,
|
|
||||||
dto: CreateReviewDto,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<EventReview>> => {
|
|
||||||
try {
|
|
||||||
const res = await reviewsApi.reviewsControllerCreate(eventId, dto)
|
|
||||||
|
|
||||||
return successResult(unwrapApiPayload(res.data))
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UPDATE_REVIEW = async (
|
|
||||||
reviewId: string,
|
|
||||||
dto: UpdateReviewDto,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<EventReview>> => {
|
|
||||||
try {
|
|
||||||
const res = await reviewsApi.reviewsControllerUpdate(reviewId, dto)
|
|
||||||
|
|
||||||
return successResult(unwrapApiPayload(res.data))
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HOST_REPLY_REVIEW = async (
|
|
||||||
reviewId: string,
|
|
||||||
dto: HostReplyDto,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<EventReview>> => {
|
|
||||||
try {
|
|
||||||
const res = await reviewsApi.reviewsControllerHostReply(reviewId, dto)
|
|
||||||
|
|
||||||
return successResult(unwrapApiPayload(res.data))
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) throw normalizedError
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import axiosInstance from '@/config/axios'
|
import axiosInstance from '@/config/axios'
|
||||||
import { texts } from '@/texts'
|
import { texts } from '@/texts'
|
||||||
import { parseRemittanceList, unwrapApiDataUnknown } from '@/helpers/listResponse'
|
import { unwrapApiDataUnknown } from '@/helpers/listResponse'
|
||||||
|
|
||||||
export interface SupportTicketMessage {
|
export interface SupportTicketMessage {
|
||||||
id: string
|
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
|
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> {
|
export async function getAdminSupportTicket(id: string): Promise<SupportTicket> {
|
||||||
const response = await axiosInstance.get(`admin/support-tickets/${id}`)
|
const response = await axiosInstance.get(`admin/support-tickets/${id}`)
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { CompleteProfileDto, UpdateUserProfileDto, UserProfileResponseDto } from '@/api/generated/models'
|
import type { CompleteProfileDto, UserProfileResponseDto } from '@/api/generated/models'
|
||||||
import {
|
import {
|
||||||
createServiceError,
|
createServiceError,
|
||||||
errorResult,
|
errorResult,
|
||||||
@ -15,76 +15,10 @@ import { texts } from '@/texts'
|
|||||||
|
|
||||||
export type UserProfile = UserProfileResponseDto
|
export type UserProfile = UserProfileResponseDto
|
||||||
export type CompleteProfilePayload = CompleteProfileDto
|
export type CompleteProfilePayload = CompleteProfileDto
|
||||||
export type UpdateProfilePayload = UpdateUserProfileDto
|
|
||||||
|
|
||||||
const usersApi = getUsers()
|
const usersApi = getUsers()
|
||||||
|
|
||||||
export const GET_ME = async (options?: ServiceCallOptions): Promise<ServiceResult<UserProfile>> => {
|
/** Completes first-time admin profile after OTP registration. */
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COMPLETE_PROFILE = async (data: CompleteProfilePayload, options?: ServiceCallOptions): Promise<ServiceResult<UserProfile>> => {
|
export const COMPLETE_PROFILE = async (data: CompleteProfilePayload, options?: ServiceCallOptions): Promise<ServiceResult<UserProfile>> => {
|
||||||
try {
|
try {
|
||||||
const res = await usersApi.usersControllerCompleteProfile({
|
const res = await usersApi.usersControllerCompleteProfile({
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
import axiosInstance from '@/config/axios'
|
|
||||||
import type { WalletResponseDto, WalletTransactionResponseDto, WalletTransactionType } from '@/api/generated/models'
|
|
||||||
import {
|
|
||||||
createServiceError,
|
|
||||||
errorResult,
|
|
||||||
handleServiceError,
|
|
||||||
type ServiceCallOptions,
|
|
||||||
type ServiceResult,
|
|
||||||
shouldBubbleErrorToParent,
|
|
||||||
successResult,
|
|
||||||
} from '@/services/errorHandler'
|
|
||||||
import { unwrapApiData } from '@/services/apiResponse'
|
|
||||||
import { getFinancial } from '@/api/generated/financial/financial'
|
|
||||||
import { parseRemittanceList } from '@/helpers/listResponse'
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
export type { WalletTransactionType }
|
|
||||||
export type Wallet = WalletResponseDto
|
|
||||||
export type WalletTransaction = WalletTransactionResponseDto
|
|
||||||
|
|
||||||
export interface WalletDepositCheckout {
|
|
||||||
trackingId: string
|
|
||||||
paymentUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateWalletDepositResult {
|
|
||||||
id: string
|
|
||||||
amount: number
|
|
||||||
status: string
|
|
||||||
checkout: WalletDepositCheckout | null
|
|
||||||
isSimulated: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MIN_WALLET_DEPOSIT_AMOUNT = 10_000
|
|
||||||
|
|
||||||
export const CREATE_WALLET_DEPOSIT = async (
|
|
||||||
amount: number,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<CreateWalletDepositResult>> => {
|
|
||||||
try {
|
|
||||||
const res = await axiosInstance.post<{ success: boolean; message?: string; data: CreateWalletDepositResult }>(
|
|
||||||
'/users/me/wallet/deposits',
|
|
||||||
{ amount }
|
|
||||||
)
|
|
||||||
const payload = res.data
|
|
||||||
|
|
||||||
if (!payload.success) {
|
|
||||||
throw createServiceError(payload.message || texts.wallet.topUpFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.data?.id) {
|
|
||||||
throw createServiceError(texts.common.invalidServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(payload.data)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET_WALLET_DEPOSIT = async (
|
|
||||||
depositId: string,
|
|
||||||
options?: ServiceCallOptions
|
|
||||||
): Promise<ServiceResult<{ id: string; status: string; amount: number }>> => {
|
|
||||||
try {
|
|
||||||
const res = await axiosInstance.get<{ success: boolean; message?: string; data: { id: string; status: string; amount: number } }>(
|
|
||||||
`/users/me/wallet/deposits/${depositId}`
|
|
||||||
)
|
|
||||||
const payload = res.data
|
|
||||||
|
|
||||||
if (!payload.success || !payload.data?.id) {
|
|
||||||
throw createServiceError(payload.message || texts.wallet.topUpFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(payload.data)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const financialApi = getFinancial()
|
|
||||||
|
|
||||||
export const GET_WALLET = async (options?: ServiceCallOptions): Promise<ServiceResult<Wallet>> => {
|
|
||||||
try {
|
|
||||||
const res = await financialApi.walletControllerGetMyWallet()
|
|
||||||
const payload = res.data
|
|
||||||
|
|
||||||
if (!payload.success) {
|
|
||||||
throw createServiceError(payload.message || texts.wallet.fetchWalletFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
const wallet = unwrapApiData(payload)
|
|
||||||
|
|
||||||
if (!wallet?.id) {
|
|
||||||
throw createServiceError(texts.common.invalidServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResult(wallet)
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LIST_WALLET_TRANSACTIONS = async (options?: ServiceCallOptions): Promise<ServiceResult<{ items: WalletTransaction[] }>> => {
|
|
||||||
try {
|
|
||||||
const res = await financialApi.walletControllerListMyTransactions({ page: 1, pageSize: 30, sort: '-createdAt' })
|
|
||||||
|
|
||||||
const payload = res.data
|
|
||||||
|
|
||||||
if (!payload.success) {
|
|
||||||
throw createServiceError(payload.message || texts.wallet.fetchTransactionsFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseRemittanceList<WalletTransaction>(unwrapApiData(payload) ?? {}, 'items')
|
|
||||||
|
|
||||||
return successResult({ items: parsed.items })
|
|
||||||
} catch (error) {
|
|
||||||
const normalizedError = handleServiceError(error, options)
|
|
||||||
|
|
||||||
if (shouldBubbleErrorToParent(options)) {
|
|
||||||
throw normalizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
return errorResult(normalizedError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +1,26 @@
|
|||||||
export const auth = {
|
export const auth = {
|
||||||
logoAlt: 'لوگوی قبیله',
|
logoAlt: 'لوگوی قبیله',
|
||||||
heroAlt: 'یک دورهمی هنری و تجربه گروهی در قبیله',
|
|
||||||
preparingLogin: 'در حال آمادهسازی ورود',
|
preparingLogin: 'در حال آمادهسازی ورود',
|
||||||
loading: 'در حال بارگذاری...',
|
|
||||||
|
|
||||||
otpTitle: 'کد ارسالشده را وارد کنید',
|
otpTitle: 'کد ارسالشده را وارد کنید',
|
||||||
profileTitle: 'تکمیل اطلاعات پروفایل',
|
profileTitle: 'تکمیل اطلاعات پروفایل',
|
||||||
welcomeTitle: 'سلام!',
|
|
||||||
welcomeDescription: 'به قبیله خوش آمدید. جهت استفاده از وبسایت وارد حساب کاربری خود شوید.',
|
|
||||||
otpLoginTitle: 'ورود با کد یکبار مصرف',
|
|
||||||
otpRegisterTitle: 'ایجاد حساب کاربری',
|
|
||||||
otpLoginDescription: 'کد ورود به شماره {mobile} ارسال شد.',
|
|
||||||
otpRegisterDescription: 'حساب کاربری با شماره موبایل {mobile} وجود ندارد. برای ساخت حساب جدید، کد تأیید برای این شماره ارسال گردید.',
|
|
||||||
profileDescription: 'جهت تکمیل حساب خود، اطلاعات خود را وارد کنید.',
|
|
||||||
termsNoticePrefix: 'ورود شما به معنای پذیرش',
|
|
||||||
termsNoticeTermsLink: 'شرایط قبیله',
|
|
||||||
privacyLink: 'قوانین حریم خصوصی',
|
|
||||||
termsNoticeConnector: 'و',
|
|
||||||
termsNoticeSuffix: 'است.',
|
|
||||||
cityPickerTitle: 'شهرتان را انتخاب کنید',
|
|
||||||
birthDatePickerTitle: 'تاریخ تولدتان را انتخاب کنید',
|
|
||||||
|
|
||||||
mobileLabel: 'شماره موبایل',
|
mobileLabel: 'شماره موبایل',
|
||||||
mobilePlaceholder: 'مثال ۰۹۱۲۳۴۵۶۷۸۹',
|
mobilePlaceholder: 'مثال ۰۹۱۲۳۴۵۶۷۸۹',
|
||||||
|
|
||||||
termsConfirmPrefix: 'تأیید میکنم',
|
termsConfirmPrefix: 'تأیید میکنم',
|
||||||
termsLink: 'قوانین و مقررات قبیله',
|
termsLink: 'قوانین و مقررات قبیله',
|
||||||
termsConfirmSuffix: 'را خواندهام و میپذیرم.',
|
termsConfirmSuffix: 'را خواندهام و میپذیرم.',
|
||||||
|
|
||||||
confirmAndContinueRegister: 'تأیید و ادامه ثبتنام',
|
confirmAndContinueRegister: 'تأیید و ادامه ثبتنام',
|
||||||
|
|
||||||
resendTimerAria: 'تایمر ارسال مجدد کد',
|
resendTimerAria: 'تایمر ارسال مجدد کد',
|
||||||
didNotReceiveCode: 'کد را دریافت نکردید؟',
|
didNotReceiveCode: 'کد را دریافت نکردید؟',
|
||||||
resendInSeconds: 'ارسال مجدد کد تا {seconds} ثانیه دیگر',
|
resendInSeconds: 'ارسال مجدد کد تا {seconds} ثانیه دیگر',
|
||||||
resendCode: 'ارسال مجدد کد',
|
resendCode: 'ارسال مجدد کد',
|
||||||
changeMobile: 'تغییر شماره موبایل',
|
changeMobile: 'تغییر شماره موبایل',
|
||||||
|
|
||||||
loadingCities: 'در حال دریافت شهرها...',
|
loadingCities: 'در حال دریافت شهرها...',
|
||||||
citiesLoadFailed: 'دریافت فهرست شهرها ناموفق بود.',
|
citiesLoadFailed: 'دریافت فهرست شهرها ناموفق بود.',
|
||||||
completeRegistration: 'تکمیل ثبتنام',
|
completeRegistration: 'تکمیل ثبتنام',
|
||||||
|
|
||||||
accountSuspended: 'حساب کاربری شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید.',
|
accountSuspended: 'حساب کاربری شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید.',
|
||||||
accountDeleted: 'این حساب کاربری حذف شده است و امکان ورود وجود ندارد.',
|
accountDeleted: 'این حساب کاربری حذف شده است و امکان ورود وجود ندارد.',
|
||||||
otpSent: 'کد تایید به شماره موبایل شما ارسال شد',
|
otpSent: 'کد تایید به شماره موبایل شما ارسال شد',
|
||||||
otpAlreadySent: 'پیامک قبلاً ارسال شده و میتوانید از همان کد استفاده کنید',
|
otpAlreadySent: 'پیامک قبلاً ارسال شده و میتوانید از همان کد استفاده کنید',
|
||||||
otpSentToNumberPrefix: 'کد تایید به شماره',
|
|
||||||
otpSentToNumberSuffix: 'ارسال شد',
|
|
||||||
otpAlreadySentToNumberPrefix: 'پیامک قبلاً به شماره',
|
|
||||||
otpAlreadySentToNumberSuffix: 'ارسال شده و همان کد هنوز معتبر است',
|
|
||||||
sendOtpFailed: 'ارسال کد با خطا مواجه شد. دوباره تلاش کنید.',
|
sendOtpFailed: 'ارسال کد با خطا مواجه شد. دوباره تلاش کنید.',
|
||||||
termsRequiredMessage: 'برای ادامه ثبتنام، پذیرش قوانین و مقررات الزامی است.',
|
termsRequiredMessage: 'برای ادامه ثبتنام، پذیرش قوانین و مقررات الزامی است.',
|
||||||
termsRequiredTitle: 'پذیرش قوانین الزامی است',
|
termsRequiredTitle: 'پذیرش قوانین الزامی است',
|
||||||
@ -55,7 +28,6 @@ export const auth = {
|
|||||||
otpInvalidOrExpired: 'کد وارد شده نامعتبر یا منقضی شده است.',
|
otpInvalidOrExpired: 'کد وارد شده نامعتبر یا منقضی شده است.',
|
||||||
otpVerifyFailed: 'تایید کد با خطا مواجه شد. دوباره تلاش کنید.',
|
otpVerifyFailed: 'تایید کد با خطا مواجه شد. دوباره تلاش کنید.',
|
||||||
profileCompleteFailed: 'تکمیل پروفایل با خطا مواجه شد. دوباره تلاش کنید.',
|
profileCompleteFailed: 'تکمیل پروفایل با خطا مواجه شد. دوباره تلاش کنید.',
|
||||||
|
|
||||||
sendOtpFailedFallback: 'ارسال کد تایید ناموفق بود',
|
sendOtpFailedFallback: 'ارسال کد تایید ناموفق بود',
|
||||||
otpInvalidFallback: 'کد تایید نامعتبر است',
|
otpInvalidFallback: 'کد تایید نامعتبر است',
|
||||||
invalidLoginResponse: 'پاسخ ورود نامعتبر است',
|
invalidLoginResponse: 'پاسخ ورود نامعتبر است',
|
||||||
|
|||||||
@ -1,33 +1,4 @@
|
|||||||
export const blog = {
|
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: {
|
errors: {
|
||||||
fetchFailed: 'دریافت مقاله ناموفق بود',
|
fetchFailed: 'دریافت مقاله ناموفق بود',
|
||||||
createFailed: 'ایجاد مقاله ناموفق بود',
|
createFailed: 'ایجاد مقاله ناموفق بود',
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
export const bookings = {
|
|
||||||
emptyServerResponse: 'پاسخ خالی از سرور',
|
|
||||||
|
|
||||||
registerForEvent: 'ثبتنام در رویداد',
|
|
||||||
loginToReserve: 'ورود و رزرو',
|
|
||||||
loginToJoinWaitlist: 'ورود و پیوستن به لیست انتظار',
|
|
||||||
joinWaitlist: 'پیوستن به لیست انتظار',
|
|
||||||
waitlistJoinedToast: 'به لیست انتظار اضافه شدید',
|
|
||||||
checkingStatusAria: 'در حال بررسی وضعیت رزرو',
|
|
||||||
statusCheckFailedTitle: 'بررسی وضعیت رزرو انجام نشد',
|
|
||||||
statusCheckFailedDescription: 'وضعیت رزرو دریافت نشد. لطفاً دوباره تلاش کنید.',
|
|
||||||
alreadyBookedGoToMyBookings: 'این رویداد رو قبلاً رزرو کردید — رزروهای من',
|
|
||||||
alsoOnWaitlist: 'در لیست انتظار این رویداد هم هستید',
|
|
||||||
onWaitlist: 'شما در لیست انتظار هستید',
|
|
||||||
waitlistNotifiedConfirm: 'یک جای خالی برات باز شد سریعتر قطعیش کن',
|
|
||||||
audienceBlockedGenderAndAge: 'سن یا جنسیت شما با شرایط این رویداد سازگار نیست.',
|
|
||||||
audienceBlockedGender: 'این رویداد برای جنسیت دیگری است.',
|
|
||||||
audienceBlockedAge: 'سن شما با شرایط این رویداد سازگار نیست.',
|
|
||||||
audienceBlockedCity: 'شهر شما با شهر این رویداد یکی نیست.',
|
|
||||||
audienceBlockedGenderAndCity: 'جنسیت یا شهر شما با شرایط این رویداد سازگار نیست.',
|
|
||||||
audienceBlockedAgeAndCity: 'سن یا شهر شما با شرایط این رویداد سازگار نیست.',
|
|
||||||
audienceBlockedGenderAgeAndCity: 'سن، جنسیت یا شهر شما با شرایط این رویداد سازگار نیست.',
|
|
||||||
audienceBlockedGeneric: 'امکان ثبتنام در این رویداد براتون وجود نداره.',
|
|
||||||
detailTitle: 'جزئیات رزرو',
|
|
||||||
notFound: 'این رزرو یافت نشد',
|
|
||||||
fetchFailedTitle: 'دریافت اطلاعات رزرو ناموفق بود',
|
|
||||||
summaryHeading: 'خلاصه رزرو',
|
|
||||||
bookingCodeLabel: 'کد رزرو',
|
|
||||||
bookingCodeWithValue: 'کد رزرو: {code}',
|
|
||||||
eventDateLabel: 'تاریخ رویداد',
|
|
||||||
ticketPriceLabel: 'قیمت بلیط',
|
|
||||||
createdAtLabel: 'تاریخ ثبت',
|
|
||||||
discountAmountLabel: 'مبلغ تخفیف',
|
|
||||||
discountCodeWithValue: 'کد تخفیف: {code}',
|
|
||||||
discountAmountInline: 'تخفیف {amount}',
|
|
||||||
cancellationReasonWithValue: 'دلیل لغو: {reason}',
|
|
||||||
cancelBooking: 'لغو رزرو',
|
|
||||||
completePayment: 'تکمیل پرداخت',
|
|
||||||
unpaidChip: 'پرداخت نشده',
|
|
||||||
checkedInChip: 'حضور ثبت شد',
|
|
||||||
cardCancelRegistration: 'لغو ثبتنام',
|
|
||||||
cardReviewPrompt: 'نظرتون رو درباره ایونت ثبت کنید',
|
|
||||||
cardPaymentExpiryWarning: 'اگر پرداخت را کامل نکنی، این ظرفیت آزاد میشود.',
|
|
||||||
cardRegistrationCodePrefix: 'کد ثبتنام:',
|
|
||||||
cardCancelledByOrganizer: 'لغو شده (برگزارکننده)',
|
|
||||||
cardCancelledByAttendee: 'لغو شده',
|
|
||||||
cardRefundedToWallet: 'مبلغ پرداختی به کیف پول شما بازگشت.',
|
|
||||||
cardRefundPending: 'مبلغ پرداختی به کیف پولت بازگردانده میشود.',
|
|
||||||
|
|
||||||
cancelled: 'رزرو لغو شد',
|
|
||||||
cancelledWithRefund: 'رزرو لغو شد — {amount} به کیف پول شما بازگشت',
|
|
||||||
confirmCancel: 'تایید لغو',
|
|
||||||
platformCommission: 'کمیسیون پلتفرم (٪{percent})',
|
|
||||||
hostCancellationFee: 'کارمزد لغو (میزبان)',
|
|
||||||
refundToWallet: 'مبلغ بازگشتی به کیف پول',
|
|
||||||
cancelNoPaymentNote: 'پرداختی انجام نشده — این کار فقط رزرو شما را لغو میکند، چیزی برای بازپرداخت نیست.',
|
|
||||||
cancellationReasonLabel: 'دلیل لغو',
|
|
||||||
cancellationReasonPlaceholder: 'لطفاً دلیل لغو رزرو را بنویسید',
|
|
||||||
|
|
||||||
copyCodeSuccess: 'کد رزرو کپی شد',
|
|
||||||
copyCodeFailed: 'کپی کد رزرو ناموفق بود',
|
|
||||||
ariaEventPage: 'صفحه رویداد',
|
|
||||||
ariaCopyBookingCode: 'کپی کد رزرو',
|
|
||||||
|
|
||||||
payTitle: 'پرداخت رزرو',
|
|
||||||
payFetchErrorTitle: 'خطایی در دریافت اطلاعات رزرو پیش آمد',
|
|
||||||
payFetchErrorDescription: 'اتصال اینترنت را بررسی کنید و دوباره تلاش کنید.',
|
|
||||||
notPayableTitle: 'امکان پرداخت این رزرو وجود ندارد',
|
|
||||||
notPayableFallback: 'این رزرو دیگر قابل پرداخت نیست.',
|
|
||||||
notPayableConfirmed: 'این رزرو قبلاً پرداخت و تایید شده است.',
|
|
||||||
notPayableCancelled: 'این رزرو لغو شده است.',
|
|
||||||
notPayableExpired: 'مهلت پرداخت این رزرو به پایان رسیده است.',
|
|
||||||
notPayableRefunded: 'این رزرو بازپرداخت شده است.',
|
|
||||||
notPayableNoShow: 'این رزرو بسته شده است.',
|
|
||||||
paymentSummaryHeading: 'خلاصه پرداخت',
|
|
||||||
discountCodePlaceholder: 'کد تخفیف را وارد کنید',
|
|
||||||
discountCodeRequired: 'کد تخفیف را وارد کنید.',
|
|
||||||
discountCodeInvalid: 'کد تخفیف معتبر نیست.',
|
|
||||||
discountHint: 'در صورت داشتن کد تخفیف، آن را وارد و اعمال کنید.',
|
|
||||||
applyDiscount: 'اعمال',
|
|
||||||
discountAppliedWithCode: '{amount} تخفیف با کد {code}',
|
|
||||||
paymentTimeRemaining: 'زمان باقیمانده برای پرداخت:',
|
|
||||||
useWalletBalance: 'استفاده از موجودی کیف پول',
|
|
||||||
currentBalanceLabel: 'موجودی فعلی: {amount}',
|
|
||||||
totalAmountLabel: 'مبلغ کل',
|
|
||||||
payableAmountLabel: 'مبلغ قابل پرداخت',
|
|
||||||
paySplitWalletAndGateway: '{wallet} از کیف پول + {gateway} شارژ از درگاه',
|
|
||||||
paySplitWalletOnly: '{wallet} از کیف پول',
|
|
||||||
paySplitGatewayOnly: '{gateway} شارژ از درگاه',
|
|
||||||
payHoldExpired: 'مهلت پرداخت به پایان رسیده است.',
|
|
||||||
gatewayPaymentFailed: 'پرداخت از درگاه ناموفق بود. دوباره تلاش کنید.',
|
|
||||||
paySeatUnavailable: 'تا زمان پرداخت مهلت رزرو تمام شد. مبلغ به کیف پول شما بازگشت.',
|
|
||||||
paymentExpiredChip: 'مهلت پرداخت تمام شد',
|
|
||||||
awaitingGatewayTitle: 'در حال انتظار برای تایید پرداخت از درگاه…',
|
|
||||||
awaitingGatewayHint: 'اگر صفحهی درگاه باز نشد، دوباره تلاش کنید یا اتصال اینترنت را بررسی کنید.',
|
|
||||||
reopenGateway: 'بازکردن دوباره درگاه پرداخت',
|
|
||||||
paySuccessTitle: 'پرداخت با موفقیت انجام شد',
|
|
||||||
paySuccessDescription: 'رزرو شما تایید شد.',
|
|
||||||
simulatedPaymentNote: 'درگاه پرداخت هنوز به سامانه متصل نشده است؛ به همین دلیل این پرداخت بهصورت آزمایشی و خودکار تایید شد.',
|
|
||||||
} as const
|
|
||||||
@ -1,74 +1,6 @@
|
|||||||
export const chats = {
|
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: 'پیوست چت',
|
chatAttachmentAlt: 'پیوست چت',
|
||||||
downloadImageAria: 'دانلود تصویر',
|
downloadImageAria: 'دانلود تصویر',
|
||||||
download: 'دانلود',
|
download: 'دانلود',
|
||||||
downloadFallbackToast: 'دانلود مستقیم ممکن نبود؛ تصویر در تب جدید باز شد',
|
downloadFallbackToast: 'دانلود مستقیم ممکن نبود؛ تصویر در تب جدید باز شد',
|
||||||
listConversationsFailed: 'دریافت مکالمات ناموفق بود',
|
|
||||||
getConversationFailed: 'دریافت مکالمه ناموفق بود',
|
|
||||||
unreadCountFailed: 'دریافت تعداد خواندهنشده ناموفق بود',
|
|
||||||
markReadFailed: 'علامتگذاری خواندهشده ناموفق بود',
|
|
||||||
listMessagesFailed: 'دریافت پیامها ناموفق بود',
|
|
||||||
openConversationFailed: 'باز کردن گفتوگو ناموفق بود',
|
|
||||||
listParticipantsFailed: 'دریافت اعضای گفتوگو ناموفق بود',
|
|
||||||
blockUserFailed: 'مسدودکردن کاربر ناموفق بود',
|
|
||||||
unblockUserFailed: 'رفع مسدودیت ناموفق بود',
|
|
||||||
blockStatusFailed: 'بررسی وضعیت مسدودسازی ناموفق بود',
|
|
||||||
listReportReasonsFailed: 'دریافت دلایل گزارش ناموفق بود',
|
|
||||||
submitReportFailed: 'ثبت گزارش ناموفق بود',
|
|
||||||
reply: 'پاسخ',
|
|
||||||
replyPreviewAria: 'پیشنمایش پیام در حال پاسخ',
|
|
||||||
cancelReplyAria: 'لغو پاسخ',
|
|
||||||
replyImageLabel: 'تصویر',
|
|
||||||
replyUnavailable: 'پیام در دسترس نیست',
|
|
||||||
jumpToReplyFailed: 'یافتن پیام مرجع ناموفق بود',
|
|
||||||
loadNewerFailed: 'بارگذاری پیامهای جدیدتر ناموفق بود',
|
|
||||||
replySenderFallback: 'کاربر',
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@ -4,7 +4,6 @@ export const common = {
|
|||||||
category: 'دستهبندی',
|
category: 'دستهبندی',
|
||||||
city: 'شهر',
|
city: 'شهر',
|
||||||
continue: 'ادامه',
|
continue: 'ادامه',
|
||||||
province: 'استان',
|
|
||||||
selectCity: 'انتخاب شهر',
|
selectCity: 'انتخاب شهر',
|
||||||
reply: 'پاسخ',
|
reply: 'پاسخ',
|
||||||
description: 'توضیحات',
|
description: 'توضیحات',
|
||||||
@ -16,52 +15,28 @@ export const common = {
|
|||||||
lastName: 'نام خانوادگی',
|
lastName: 'نام خانوادگی',
|
||||||
lastNamePlaceholder: 'نام خانوادگی خود را وارد کنید',
|
lastNamePlaceholder: 'نام خانوادگی خود را وارد کنید',
|
||||||
birthDatePlaceholder: 'تاریخ تولد را انتخاب کنید',
|
birthDatePlaceholder: 'تاریخ تولد را انتخاب کنید',
|
||||||
loadingEllipsis: 'در حال بارگذاری…',
|
|
||||||
imageUploadFailed: 'آپلود تصویر ناموفق بود',
|
imageUploadFailed: 'آپلود تصویر ناموفق بود',
|
||||||
payment: 'پرداخت',
|
|
||||||
discountCode: 'کد تخفیف',
|
|
||||||
instagram: 'اینستاگرام',
|
|
||||||
telegram: 'تلگرام',
|
|
||||||
general: 'عمومی',
|
|
||||||
account: 'حساب کاربری',
|
|
||||||
operationFailed: 'عملیات ناموفق بود',
|
|
||||||
invalidServerResponse: 'پاسخ نامعتبر از سرور',
|
invalidServerResponse: 'پاسخ نامعتبر از سرور',
|
||||||
saveChanges: 'ذخیره تغییرات',
|
saveChanges: 'ذخیره تغییرات',
|
||||||
|
|
||||||
home: 'خانه',
|
home: 'خانه',
|
||||||
myEvents: 'رویدادهای من',
|
|
||||||
chat: 'چت',
|
|
||||||
profile: 'پروفایل',
|
|
||||||
login: 'ورود',
|
login: 'ورود',
|
||||||
bottomNavAria: 'ناوبری پایین',
|
|
||||||
unreadMessagesAria: '{label}، {count} پیام خواندهنشده',
|
|
||||||
unreadBadgeOverflow: '۹۹+',
|
|
||||||
back: 'بازگشت',
|
back: 'بازگشت',
|
||||||
retry: 'تلاش دوباره',
|
retry: 'تلاش دوباره',
|
||||||
backHome: 'بازگشت به خانه',
|
backHome: 'بازگشت به خانه',
|
||||||
|
|
||||||
loadingCards: 'در حال دریافت کارتها',
|
|
||||||
loadingList: 'در حال دریافت فهرست',
|
loadingList: 'در حال دریافت فهرست',
|
||||||
loadingDetails: 'در حال دریافت جزئیات',
|
loadingDetails: 'در حال دریافت جزئیات',
|
||||||
loadingTable: 'در حال دریافت جدول',
|
loadingTable: 'در حال دریافت جدول',
|
||||||
preparingPage: 'در حال آمادهسازی صفحه',
|
preparingPage: 'در حال آمادهسازی صفحه',
|
||||||
pleaseWait: 'لطفاً چند لحظه صبر کنید',
|
pleaseWait: 'لطفاً چند لحظه صبر کنید',
|
||||||
preparingPanel: 'در حال آمادهسازی پنل شما',
|
preparingPanel: 'در حال آمادهسازی پنل شما',
|
||||||
preparingEvent: 'در حال آمادهسازی رویداد',
|
|
||||||
loadingEventDetails: 'در حال دریافت اطلاعات رویداد',
|
|
||||||
|
|
||||||
somethingWentWrong: 'مشکلی رخ داده است',
|
somethingWentWrong: 'مشکلی رخ داده است',
|
||||||
tryAgainOrSupport: 'میتوانید دوباره تلاش کنید. اگر خطا ادامه داشت، شناسه پیگیری را برای پشتیبانی ارسال کنید.',
|
tryAgainOrSupport: 'میتوانید دوباره تلاش کنید. اگر خطا ادامه داشت، شناسه پیگیری را برای پشتیبانی ارسال کنید.',
|
||||||
trackingId: 'شناسه پیگیری: {digest}',
|
trackingId: 'شناسه پیگیری: {digest}',
|
||||||
|
|
||||||
accountSuspendedBanner: 'حساب کاربری شما مسدود شده است. برای پیگیری و فعالسازی مجدد با پشتیبانی تماس بگیرید.',
|
accountSuspendedBanner: 'حساب کاربری شما مسدود شده است. برای پیگیری و فعالسازی مجدد با پشتیبانی تماس بگیرید.',
|
||||||
contactSupport: 'تماس با پشتیبانی',
|
contactSupport: 'تماس با پشتیبانی',
|
||||||
|
|
||||||
offlineBanner: 'اتصال اینترنت قطع است؛ تغییرات پس از برقراری اتصال دوباره قابل ارسال خواهند بود.',
|
offlineBanner: 'اتصال اینترنت قطع است؛ تغییرات پس از برقراری اتصال دوباره قابل ارسال خواهند بود.',
|
||||||
|
|
||||||
notFoundTitle: 'یافت نشد',
|
notFoundTitle: 'یافت نشد',
|
||||||
pageNotFound: 'صفحهای که دنبال آن بودید یافت نشد.',
|
pageNotFound: 'صفحهای که دنبال آن بودید یافت نشد.',
|
||||||
|
|
||||||
enterField: '{label} را وارد کنید',
|
enterField: '{label} را وارد کنید',
|
||||||
formIncomplete: 'اطلاعات فرم کامل نیست',
|
formIncomplete: 'اطلاعات فرم کامل نیست',
|
||||||
formValidationDefault: 'لطفاً اطلاعات فرم را بررسی و خطاهای مشخصشده را برطرف کنید.',
|
formValidationDefault: 'لطفاً اطلاعات فرم را بررسی و خطاهای مشخصشده را برطرف کنید.',
|
||||||
@ -69,7 +44,6 @@ export const common = {
|
|||||||
showPassword: 'نمایش رمز عبور',
|
showPassword: 'نمایش رمز عبور',
|
||||||
hidePassword: 'پنهانکردن رمز عبور',
|
hidePassword: 'پنهانکردن رمز عبور',
|
||||||
toman: 'تومان',
|
toman: 'تومان',
|
||||||
|
|
||||||
uploadFile: 'آپلود فایل',
|
uploadFile: 'آپلود فایل',
|
||||||
fileMustBeAccepted: 'فایل انتخاب شده باید یک فایل مورد قبول باشد.',
|
fileMustBeAccepted: 'فایل انتخاب شده باید یک فایل مورد قبول باشد.',
|
||||||
imageFileTooLarge: 'حجم فایل تصویری نباید بیشتر از {maxMb} مگابایت باشد.',
|
imageFileTooLarge: 'حجم فایل تصویری نباید بیشتر از {maxMb} مگابایت باشد.',
|
||||||
@ -82,7 +56,6 @@ export const common = {
|
|||||||
removeFile: 'حذف فایل',
|
removeFile: 'حذف فایل',
|
||||||
removeFileNamed: 'حذف فایل {name}',
|
removeFileNamed: 'حذف فایل {name}',
|
||||||
cancelUpload: 'لغو بارگذاری فایل',
|
cancelUpload: 'لغو بارگذاری فایل',
|
||||||
|
|
||||||
imagePrepareFailed: 'آمادهسازی تصویر ناموفق بود',
|
imagePrepareFailed: 'آمادهسازی تصویر ناموفق بود',
|
||||||
imageConvertFailed: 'تبدیل تصویر ناموفق بود',
|
imageConvertFailed: 'تبدیل تصویر ناموفق بود',
|
||||||
imageProcessingTimedOut: 'پردازش تصویر بیش از حد طول کشید. لطفاً تصویر کوچکتری انتخاب کنید.',
|
imageProcessingTimedOut: 'پردازش تصویر بیش از حد طول کشید. لطفاً تصویر کوچکتری انتخاب کنید.',
|
||||||
@ -90,10 +63,8 @@ export const common = {
|
|||||||
imageEmpty: 'فایل تصویر خالی است',
|
imageEmpty: 'فایل تصویر خالی است',
|
||||||
imageTooLarge: 'حجم تصویر نباید بیشتر از {maxMb} مگابایت باشد',
|
imageTooLarge: 'حجم تصویر نباید بیشتر از {maxMb} مگابایت باشد',
|
||||||
imageTypeNotAllowed: 'فقط تصاویر JPEG، PNG، WebP یا HEIC مجاز هستند',
|
imageTypeNotAllowed: 'فقط تصاویر JPEG، PNG، WebP یا HEIC مجاز هستند',
|
||||||
|
|
||||||
unexpectedError: 'خطای پیشبینینشده',
|
unexpectedError: 'خطای پیشبینینشده',
|
||||||
requestCancelled: 'درخواست لغو شد',
|
requestCancelled: 'درخواست لغو شد',
|
||||||
|
|
||||||
today: 'امروز',
|
today: 'امروز',
|
||||||
tomorrow: 'فردا',
|
tomorrow: 'فردا',
|
||||||
yesterday: 'دیروز',
|
yesterday: 'دیروز',
|
||||||
@ -117,16 +88,11 @@ export const common = {
|
|||||||
birthDate: 'تاریخ تولد',
|
birthDate: 'تاریخ تولد',
|
||||||
selectDate: 'انتخاب تاریخ',
|
selectDate: 'انتخاب تاریخ',
|
||||||
selectTime: 'انتخاب ساعت',
|
selectTime: 'انتخاب ساعت',
|
||||||
|
|
||||||
invalidUrl: 'نشانی واردشده معتبر نیست.',
|
invalidUrl: 'نشانی واردشده معتبر نیست.',
|
||||||
imageAltLabel: 'توضیح تصویر (alt)',
|
imageAltLabel: 'توضیح تصویر (alt)',
|
||||||
imageAltDescription: 'برای دسترسپذیری و سئو تصویر لازم است (مثلاً «نمای سالن کارگاه عکاسی»).',
|
imageAltDescription: 'برای دسترسپذیری و سئو تصویر لازم است (مثلاً «نمای سالن کارگاه عکاسی»).',
|
||||||
|
|
||||||
jalaliMonths: ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'],
|
jalaliMonths: ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'],
|
||||||
|
|
||||||
unsavedChanges: 'تغییرات ذخیرهنشده دارید',
|
unsavedChanges: 'تغییرات ذخیرهنشده دارید',
|
||||||
noUnsavedChanges: 'تغییری برای ذخیره وجود ندارد',
|
noUnsavedChanges: 'تغییری برای ذخیره وجود ندارد',
|
||||||
seoCharMinHint: ' (حداقل {min})',
|
seoCharMinHint: ' (حداقل {min})',
|
||||||
listJoin: '، ',
|
|
||||||
breadcrumbAria: 'مسیر صفحه',
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@ -1,124 +1,3 @@
|
|||||||
export const discovery = {
|
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: 'ظرفیت تکمیل شده',
|
capacityFull: 'ظرفیت تکمیل شده',
|
||||||
viewEventAria: 'مشاهده رویداد {title}',
|
|
||||||
eventPosterAlt: 'پوستر رویداد {title}',
|
|
||||||
|
|
||||||
categoriesTitle: 'دستهبندیها',
|
|
||||||
categoriesDescription: 'موضوع مورد علاقهتان را برای پیدا کردن رویداد انتخاب کنید',
|
|
||||||
categoriesFetchFailedTitle: 'دریافت دستهبندیها ناموفق بود',
|
|
||||||
categoriesEmpty: 'دستهبندیای یافت نشد',
|
|
||||||
viewCategoryEvents: 'مشاهده رویدادها',
|
|
||||||
|
|
||||||
latestEventsPrefix: 'جدیدترین رویدادهای',
|
|
||||||
homePopularListAria: 'رویدادهای پرطرفدار',
|
|
||||||
homeCategoryPreviewAria: 'جدیدترین رویدادهای دسته',
|
|
||||||
ariaPreviousEvent: 'رویداد قبلی',
|
|
||||||
ariaNextEvent: 'رویداد بعدی',
|
|
||||||
ariaActiveEvent: 'رویداد فعال: {title}',
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
408
texts/events.ts
408
texts/events.ts
@ -1,114 +1,18 @@
|
|||||||
export const events = {
|
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: 'رایگان',
|
free: 'رایگان',
|
||||||
save: 'ذخیره',
|
|
||||||
edit: 'ویرایش',
|
|
||||||
nextStep: 'مرحله بعد',
|
nextStep: 'مرحله بعد',
|
||||||
saveAndContinue: 'ثبت و ادامه',
|
|
||||||
startOver: 'شروع از ابتدا',
|
|
||||||
configureEventLink: 'تنظیم لینک رویداد',
|
|
||||||
collapseDetails: 'بستن جزئیات',
|
|
||||||
timeRange: '{start} تا {end}',
|
timeRange: '{start} تا {end}',
|
||||||
dateRangeMultiDay: 'از {start} تا {end}',
|
|
||||||
prevStep: 'مرحله قبل',
|
prevStep: 'مرحله قبل',
|
||||||
return: 'برگشت',
|
|
||||||
guest: 'مهمان',
|
guest: 'مهمان',
|
||||||
host: 'میزبان',
|
host: 'میزبان',
|
||||||
start: 'شروع',
|
|
||||||
end: 'پایان',
|
|
||||||
location: 'مکان',
|
|
||||||
price: 'قیمت',
|
|
||||||
capacity: 'ظرفیت',
|
capacity: 'ظرفیت',
|
||||||
title: 'عنوان',
|
title: 'عنوان',
|
||||||
address: 'آدرس',
|
address: 'آدرس',
|
||||||
faqs: 'سوالات متداول',
|
|
||||||
aboutEvent: 'درباره رویداد',
|
|
||||||
locationCardTitle: 'مکان رویداد',
|
|
||||||
dateCardTitle: 'تاریخ رویداد',
|
|
||||||
timeCardTitle: 'زمان رویداد',
|
|
||||||
categoryCardTitle: 'دسته بندی',
|
|
||||||
priceCardTitle: 'هزینه رویداد',
|
|
||||||
navigateWithNeshan: 'مسیریابی با نشان',
|
|
||||||
navigateWithGoogleMaps: 'مسیریابی با گوگل مپ',
|
|
||||||
ageRestrictionChip: 'محدودیت سنی {label}',
|
|
||||||
ageChip15_19: '۱۵-۱۹ سال',
|
|
||||||
ageChip20_24: '۲۰-۲۴ سال',
|
|
||||||
ageChip25Plus: '۲۵+ سال',
|
|
||||||
peopleLimitChip: 'محدودیت',
|
|
||||||
chipFemaleOnly: 'خانمها',
|
|
||||||
chipMaleOnly: 'آقایان',
|
|
||||||
reviews: 'نظرات',
|
reviews: 'نظرات',
|
||||||
waitlist: 'لیست انتظار',
|
|
||||||
eventEnded: 'اتمام رویداد',
|
eventEnded: 'اتمام رویداد',
|
||||||
invalidDate: 'تاریخ نامعتبر است',
|
invalidDate: 'تاریخ نامعتبر است',
|
||||||
tomanSuffix: ' تومان',
|
|
||||||
uploadFailed: 'آپلود ناموفق بود',
|
uploadFailed: 'آپلود ناموفق بود',
|
||||||
personCount: '{count} نفر',
|
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: 'دریافت اطلاعات مهمانان ناموفق بود.',
|
guestAccessLoadFailed: 'دریافت اطلاعات مهمانان ناموفق بود.',
|
||||||
guestAccessTitle: 'مهمانان ویژه',
|
guestAccessTitle: 'مهمانان ویژه',
|
||||||
guestAccessLoading: 'در حال دریافت اطلاعات مهمانان…',
|
guestAccessLoading: 'در حال دریافت اطلاعات مهمانان…',
|
||||||
@ -120,138 +24,27 @@ export const events = {
|
|||||||
guestListsEmpty: 'لیست مهمانی به این رویداد متصل نشده است.',
|
guestListsEmpty: 'لیست مهمانی به این رویداد متصل نشده است.',
|
||||||
invitedGuestsTitle: 'مهمانان دعوتشده',
|
invitedGuestsTitle: 'مهمانان دعوتشده',
|
||||||
invitedGuestsEmpty: 'مهمانی برای دعوت انتخاب نشده است.',
|
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: 'رویداد یافت نشد',
|
eventNotFound: 'رویداد یافت نشد',
|
||||||
eventLoadFailedTitle: 'دریافت اطلاعات رویداد ناموفق بود',
|
|
||||||
eventLoadFailedDescription: 'این رویداد یافت نشد یا دیگر در دسترس نیست.',
|
|
||||||
eventUnavailable: 'این رویداد در حال حاضر در دسترس نیست',
|
|
||||||
eventImageAlt: 'تصویر رویداد {title}',
|
|
||||||
eventThumbAlt: 'تصویر بندانگشتی رویداد {title}',
|
|
||||||
eventDefaultImageAlt: 'تصویر پیشفرض رویداد {title}',
|
|
||||||
addressHiddenUntilConfirmed: 'آدرس دقیق پس از تأیید رزرو نمایش داده میشود.',
|
|
||||||
guestRating: 'امتیاز مهمانان',
|
|
||||||
reviewCountParen: '({count} نظر)',
|
|
||||||
hostProfileAria: 'پروفایل {name}',
|
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: 'زمان و مکان',
|
wizardStep2Label: 'زمان و مکان',
|
||||||
wizardStep3Label: 'بلیط و ظرفیت',
|
|
||||||
wizardStep4Label: 'بازبینی نهایی',
|
|
||||||
wizardStep1Hint: 'عنوان، دستهبندی، توضیحات و تصاویر رویداد',
|
|
||||||
wizardStep2Hint: 'زمانبندی و محل برگزاری رویداد',
|
wizardStep2Hint: 'زمانبندی و محل برگزاری رویداد',
|
||||||
wizardStep3Hint: 'ظرفیت، قیمت و جریمه لغو',
|
wizardStep3Hint: 'ظرفیت، قیمت و جریمه لغو',
|
||||||
wizardStep4Hint: 'اطلاعات را از نگاه مهمان بررسی کن و پیشنویس را بساز',
|
|
||||||
wizardTitle: 'رویدادت را بساز',
|
|
||||||
wizardDescription: 'اطلاعات اصلی را مرحلهبهمرحله کامل کن؛ تا زمانی که پیشنویس را نساختهای، تغییرات روی همین دستگاه نگهداری میشوند.',
|
|
||||||
wizardAutosaveOn: 'ذخیرهٔ خودکار روشن است',
|
|
||||||
wizardDraftRestoredStatus: 'پیشنویس بازیابی شد',
|
|
||||||
wizardCloneReadyStatus: 'کپی آمادهٔ ویرایش',
|
|
||||||
wizardAutosaveHint: 'تغییرات این فرم بهصورت خودکار روی این دستگاه ذخیره میشوند.',
|
|
||||||
wizardDraftRestoredHint: 'پیشنویس قبلی بازیابی شد. تغییرات بهصورت خودکار ذخیره میشوند.',
|
|
||||||
wizardCloneHint: 'اطلاعات رویداد قبلی کپی شد. در صورت نیاز تغییر دهید و رویداد جدید را ذخیره کنید.',
|
|
||||||
wizardCloneLoadFailed: 'بارگذاری رویداد برای کپی ناموفق بود',
|
|
||||||
postersRequiredTitle: 'هر دو پوستر الزامی است',
|
postersRequiredTitle: 'هر دو پوستر الزامی است',
|
||||||
postersRequiredDescription: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) را آپلود کنید و سپس پیشنویس را ذخیره کنید.',
|
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: 'پوستر عمودی و پوستر مربعی هر دو الزامیاند. تصاویر گالری اختیاریاند و کراپ نمیشوند.',
|
mediaSectionDescription: 'پوستر عمودی و پوستر مربعی هر دو الزامیاند. تصاویر گالری اختیاریاند و کراپ نمیشوند.',
|
||||||
basicInfoSectionDescription: 'اطلاعاتی را وارد کن که به مهمان کمک میکند سریع موضوع رویداد را بفهمد.',
|
|
||||||
eventTitleLabel: 'عنوان رویداد',
|
|
||||||
shortDescriptionLabel: 'معرفی کوتاه',
|
shortDescriptionLabel: 'معرفی کوتاه',
|
||||||
fullDescriptionLabel: 'توضیحات کامل',
|
|
||||||
advanced: '(پیشرفته)',
|
|
||||||
slugLabel: 'لینک اختصاصی(پیشرفته)',
|
|
||||||
slugHintFromTitle: 'از روی عنوان ساخته میشود؛ در صورت نیاز میتوانید عوض کنید.',
|
|
||||||
slugPublicLink: 'لینک عمومی: /events/{slug}',
|
|
||||||
categoryPlaceholder: 'یک دستهبندی را انتخاب کنید',
|
categoryPlaceholder: 'یک دستهبندی را انتخاب کنید',
|
||||||
|
|
||||||
// Step 2
|
|
||||||
addressVisibilityAttendeesOnly: 'فقط برای رزروکنندگان تأییدشده',
|
addressVisibilityAttendeesOnly: 'فقط برای رزروکنندگان تأییدشده',
|
||||||
addressVisibilityPublic: 'نمایش برای همه',
|
addressVisibilityPublic: 'نمایش برای همه',
|
||||||
citiesLoadFailed: 'بارگذاری شهرها ناموفق بود',
|
|
||||||
scheduleSectionDescription: 'شروع و پایان را دقیق وارد کن؛ پس از اولین رزرو، تغییر این اطلاعات محدود میشود.',
|
|
||||||
startsAtDateLabel: 'تاریخ شروع',
|
startsAtDateLabel: 'تاریخ شروع',
|
||||||
startsAtTimeLabel: 'ساعت شروع',
|
startsAtTimeLabel: 'ساعت شروع',
|
||||||
endsAtDateLabel: 'تاریخ پایان',
|
endsAtDateLabel: 'تاریخ پایان',
|
||||||
endsAtTimeLabel: 'ساعت پایان',
|
endsAtTimeLabel: 'ساعت پایان',
|
||||||
venueSectionTitle: 'مکان برگزاری',
|
|
||||||
venueSectionDescription: 'شهر، آدرس و نقطهٔ دقیق محل برگزاری را مشخص کن.',
|
|
||||||
consumerVenueSectionDescription: 'آدرس و نقطهٔ دقیق محل برگزاری را مشخص کن.',
|
|
||||||
hostCityLabel: 'شهر رویداد',
|
|
||||||
hostCityMissing: 'شهر پروفایل شما مشخص نیست. ابتدا شهر را در پروفایل تکمیل کنید.',
|
|
||||||
addressPlaceholder: 'آدرس محل برگزاری',
|
|
||||||
addressHostContactHint: 'پیشفرض از «اطلاعات تماس» میزبان؛ در صورت نیاز ویرایش کنید.',
|
|
||||||
addressVisibilitySectionDescription: 'اگر محل خصوصی است، میتوانی آدرس دقیق را فقط به مهمانان قطعی نشان بدهی.',
|
|
||||||
addressVisibilityLabel: 'نمایش آدرس دقیق',
|
addressVisibilityLabel: 'نمایش آدرس دقیق',
|
||||||
generalAreaLabel: 'منطقه کلی ',
|
generalAreaLabel: 'منطقه کلی ',
|
||||||
generalAreaPlaceholder: 'مثلاً: مشهد، خیابان هفتتیر',
|
generalAreaPlaceholder: 'مثلاً: مشهد، خیابان هفتتیر',
|
||||||
mapTitle: 'موقعیت روی نقشه',
|
mapTitle: 'موقعیت روی نقشه',
|
||||||
mapLocked: 'موقعیت بهخاطر رزرو فعال مهمانها قفل شده است.',
|
mapLocked: 'موقعیت بهخاطر رزرو فعال مهمانها قفل شده است.',
|
||||||
reservedCapacityEditLockedHint: 'ظرفیت رزرو خارج از قبیله را از صفحه مدیریت رویداد تغییر دهید.',
|
|
||||||
mapHint: 'روی نقشه کلیک کنید تا موقعیت انتخاب شود؛ آدرس بهصورت خودکار پر میشود و قابل ویرایش است.',
|
mapHint: 'روی نقشه کلیک کنید تا موقعیت انتخاب شود؛ آدرس بهصورت خودکار پر میشود و قابل ویرایش است.',
|
||||||
mapModalHint: 'برای تعیین نقطهٔ دقیق، نقشه را باز کنید؛ آدرس پس از انتخاب پر میشود و قابل ویرایش است.',
|
mapModalHint: 'برای تعیین نقطهٔ دقیق، نقشه را باز کنید؛ آدرس پس از انتخاب پر میشود و قابل ویرایش است.',
|
||||||
mapSelectModalTitle: 'انتخاب مکان از روی نقشه',
|
mapSelectModalTitle: 'انتخاب مکان از روی نقشه',
|
||||||
@ -259,66 +52,19 @@ export const events = {
|
|||||||
mapKeyMissing: 'کلید نقشه (MAP_API_KEY) تنظیم نشده است',
|
mapKeyMissing: 'کلید نقشه (MAP_API_KEY) تنظیم نشده است',
|
||||||
mapLoading: 'در حال بارگذاری نقشه…',
|
mapLoading: 'در حال بارگذاری نقشه…',
|
||||||
mapAddressNotFound: 'آدرسی برای این نقطه پیدا نشد',
|
mapAddressNotFound: 'آدرسی برای این نقطه پیدا نشد',
|
||||||
|
|
||||||
// Step 3
|
|
||||||
pricingSectionDescription: 'نوع ثبتنام را انتخاب کن؛ برای رویداد پولی، مبلغ نهایی بلیط را وارد کن.',
|
|
||||||
ticketTypeSectionTitle: 'نوع بلیط',
|
|
||||||
ticketPaid: 'با تعیین قیمت',
|
|
||||||
reservedCapacityWizardHint:
|
|
||||||
'مهمانهایی که از قبل یا از کانال دیگری (مثلاً اینستاگرام) رزرو کردهاند. این عدد از ظرفیت قابلرزرو در قبیله کم میشود.',
|
|
||||||
audienceSectionTitle: 'محدودیت شرکتکنندگان',
|
|
||||||
cancellationSectionTitle: 'لغو و بازپرداخت',
|
|
||||||
moreSettings: 'تنظیمات بیشتر',
|
|
||||||
pricePerTicketLabel: 'قیمت هر بلیط (تومان)',
|
|
||||||
reservedCapacityLabel: 'ظرفیت رزرو شده',
|
|
||||||
audienceSectionDescription: 'اگر رویداد برای همه نیست، محدودیت جنسیت یا سن را انتخاب کن. پیشفرض آزاد است.',
|
|
||||||
genderRestrictionLabel: 'محدودیت جنسی',
|
genderRestrictionLabel: 'محدودیت جنسی',
|
||||||
peopleRestrictionLabel: 'محدودیتافراد',
|
|
||||||
ageRestrictionLabel: 'محدودیت سنی',
|
ageRestrictionLabel: 'محدودیت سنی',
|
||||||
cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان میدهیم تا تصمیم شفافی بگیرد.',
|
|
||||||
cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده',
|
cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده',
|
||||||
cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده',
|
cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده',
|
||||||
cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده',
|
cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده',
|
||||||
advancedSettingsHint: 'پیشنهاد میکنیم مقادیر پیشفرض را نگه داری',
|
|
||||||
discoverableLabel: 'نمایش در جستجوی عمومی',
|
discoverableLabel: 'نمایش در جستجوی عمومی',
|
||||||
autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد',
|
|
||||||
waitlistAutoOfferLabel: 'پیشنهاد خودکار جای خالی به لیست انتظار',
|
waitlistAutoOfferLabel: 'پیشنهاد خودکار جای خالی به لیست انتظار',
|
||||||
sendReviewSmsLabel: 'پیامک درخواست نظر پس از رویداد',
|
|
||||||
|
|
||||||
// Step 4 preview / FAQ
|
|
||||||
previewSectionDescription: 'این خلاصه همان اطلاعاتی است که مهمان برای تصمیمگیری به آن نیاز دارد.',
|
|
||||||
posterAria: 'پوستر رویداد',
|
|
||||||
noPosterAria: 'رویداد بدون پوستر',
|
|
||||||
eventCategoryFallback: 'دستهبندی رویداد',
|
|
||||||
readyToSave: 'آمادهٔ ذخیره است',
|
|
||||||
previewEventTitle: 'پیشنمایش رویداد',
|
|
||||||
checklistIntroDone: 'معرفی و دستهبندی تکمیل شده',
|
|
||||||
checklistScheduleDone: 'زمان و مکان مشخص است',
|
|
||||||
checklistPricingDone: 'نوع بلیط و ظرفیت تعیین شده',
|
|
||||||
checklistPosterDone: 'پوستر رویداد انتخاب شده',
|
|
||||||
addQuestion: 'افزودن سوال',
|
addQuestion: 'افزودن سوال',
|
||||||
faqSectionDescription: 'اگر مهمانها معمولاً سؤال مشخصی دارند، همینجا پاسخ آن را اضافه کن. این بخش اختیاری است.',
|
faqSectionDescription: 'اگر مهمانها معمولاً سؤال مشخصی دارند، همینجا پاسخ آن را اضافه کن. این بخش اختیاری است.',
|
||||||
questionLabel: 'سوال',
|
questionLabel: 'سوال',
|
||||||
saveDraft: 'ذخیره پیشنویس',
|
|
||||||
|
|
||||||
// Step 5 guests
|
|
||||||
guestsSaved: 'مهمانان انتخاب شدند. بعد از انتشار رویداد پیامک دعوت برایشان ارسال میشود',
|
|
||||||
guestsSaveFailed: 'ذخیره مهمانان ناموفق بود',
|
|
||||||
guestsSectionDescription:
|
|
||||||
'از میان افرادی که قبلاً در رویدادهای شما حضور داشتهاند انتخاب کنید. بعد از انتشار رویداد، پیامک دعوت برایشان ارسال میشود. این مرحله اختیاری است.',
|
|
||||||
sendInvitation: 'ذخیره مهمانان',
|
|
||||||
selectAllGuests: 'انتخاب همه',
|
|
||||||
guestSearchAria: 'جستجوی کاربران',
|
|
||||||
guestSearchPlaceholder: 'نام مهمان را جستجو کنید',
|
|
||||||
finish: 'اتمام',
|
|
||||||
|
|
||||||
// Media uploader
|
|
||||||
imageReadFailed: 'خواندن تصویر ناموفق بود',
|
imageReadFailed: 'خواندن تصویر ناموفق بود',
|
||||||
postersBothRequired: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) هر دو الزامی هستند.',
|
|
||||||
portraitPosterLabel: 'پوستر عمودی',
|
|
||||||
portraitPosterEmpty: 'افزودن پوستر عمودی',
|
portraitPosterEmpty: 'افزودن پوستر عمودی',
|
||||||
portraitPosterHint: 'نسبت ۹:۱۶',
|
portraitPosterHint: 'نسبت ۹:۱۶',
|
||||||
squarePosterLabel: 'پوستر مربعی',
|
|
||||||
squarePosterEmpty: 'افزودن پوستر مربعی',
|
squarePosterEmpty: 'افزودن پوستر مربعی',
|
||||||
squarePosterHint: 'نسبت ۱:۱',
|
squarePosterHint: 'نسبت ۱:۱',
|
||||||
galleryTitle: 'تصاویر گالری',
|
galleryTitle: 'تصاویر گالری',
|
||||||
@ -336,21 +82,13 @@ export const events = {
|
|||||||
cropPortraitHint: 'پوستر کارتهای عمودی با نسبت ۹:۱۶ برش داده میشود.',
|
cropPortraitHint: 'پوستر کارتهای عمودی با نسبت ۹:۱۶ برش داده میشود.',
|
||||||
pickOtherSquarePoster: 'انتخاب عکس دیگر برای پوستر مربعی',
|
pickOtherSquarePoster: 'انتخاب عکس دیگر برای پوستر مربعی',
|
||||||
change: 'تغییر',
|
change: 'تغییر',
|
||||||
|
|
||||||
// New / identity gate
|
|
||||||
createEventPageTitle: 'ایجاد رویداد جدید',
|
|
||||||
nationalIdImageLabel: 'تصویر کارت ملی',
|
|
||||||
|
|
||||||
// Edit
|
|
||||||
editEventTitle: 'ویرایش',
|
editEventTitle: 'ویرایش',
|
||||||
invalidEventId: 'شناسه رویداد نامعتبر است',
|
invalidEventId: 'شناسه رویداد نامعتبر است',
|
||||||
eventLoadFailed: 'بارگذاری رویداد ناموفق بود',
|
eventLoadFailed: 'بارگذاری رویداد ناموفق بود',
|
||||||
eventUpdated: 'رویداد بهروزرسانی شد',
|
eventUpdated: 'رویداد بهروزرسانی شد',
|
||||||
eventUpdateFailed: 'ذخیره تغییرات ناموفق بود',
|
eventUpdateFailed: 'ذخیره تغییرات ناموفق بود',
|
||||||
editOwnerOnly: 'فقط سازندهی رویداد میتواند آن را ویرایش کند.',
|
|
||||||
editStatusBlocked: 'این رویداد در وضعیت «{status}» دیگر قابل ویرایش نیست.',
|
editStatusBlocked: 'این رویداد در وضعیت «{status}» دیگر قابل ویرایش نیست.',
|
||||||
capacityBelowOccupied: 'ظرفیت نمیتواند کمتر از صندلیهای اشغالشده ({min}) باشد',
|
capacityBelowOccupied: 'ظرفیت نمیتواند کمتر از صندلیهای اشغالشده ({min}) باشد',
|
||||||
capacityMinOccupiedHint: 'با توجه به رزروهای فعال، ظرفیت نمیتواند کمتر از {min} باشد.',
|
|
||||||
basicInfoCardTitle: 'اطلاعات پایه',
|
basicInfoCardTitle: 'اطلاعات پایه',
|
||||||
basicInfoCardDescription: 'عنوان، دستهبندی و توضیحات اصلی رویداد',
|
basicInfoCardDescription: 'عنوان، دستهبندی و توضیحات اصلی رویداد',
|
||||||
slugLockedHint: 'پس از انتشار، تغییر اسلاگ تا تایید ادمین روی لینک عمومی اعمال نمیشود.',
|
slugLockedHint: 'پس از انتشار، تغییر اسلاگ تا تایید ادمین روی لینک عمومی اعمال نمیشود.',
|
||||||
@ -362,16 +100,6 @@ export const events = {
|
|||||||
autoCreateGroupAfterEndLabel: 'ساخت خودکار گروه چت بعد از پایان رویداد',
|
autoCreateGroupAfterEndLabel: 'ساخت خودکار گروه چت بعد از پایان رویداد',
|
||||||
sendReviewSmsAfterEndLabel: 'ارسال پیامک درخواست نظر بعد از پایان رویداد',
|
sendReviewSmsAfterEndLabel: 'ارسال پیامک درخواست نظر بعد از پایان رویداد',
|
||||||
editLockedWithBookings: 'این رویداد {count} رزرو فعال دارد؛ زمان، مکان، قیمت، جریمه لغو، محدودیت جنسی/سنی و تنظیمات رفتاری قفل شدهاند.',
|
editLockedWithBookings: 'این رویداد {count} رزرو فعال دارد؛ زمان، مکان، قیمت، جریمه لغو، محدودیت جنسی/سنی و تنظیمات رفتاری قفل شدهاند.',
|
||||||
editRevisionConfirmTitle: 'نیاز به بررسی مجدد ادمین',
|
|
||||||
editRevisionConfirmMessage:
|
|
||||||
'با ذخیره، تغییرات برای بررسی ادمین ارسال میشود. تا زمان تایید، مهمانها همان نسخهٔ فعلی را میبینند. پس از تایید ادمین، رویداد با اطلاعات جدید فوراً منتشر میشود و نیازی به تایید دوباره شما نیست.',
|
|
||||||
editRevisionConfirmAccept: 'ارسال برای بررسی',
|
|
||||||
editRevisionSubmitted: 'ویرایش برای بررسی ادمین ارسال شد',
|
|
||||||
editRevisionPendingBanner: 'یک ویرایش در انتظار تایید ادمین دارید. ذخیرهٔ دوباره جایگزین آن میشود.',
|
|
||||||
editRevisionWithdraw: 'انصراف از ویرایش',
|
|
||||||
editRevisionWithdrawn: 'ویرایش لغو شد',
|
|
||||||
editRevisionWithdrawFailed: 'لغو ویرایش ناموفق بود',
|
|
||||||
editSaved: 'تغییرات ذخیره شد',
|
|
||||||
editMediaCardTitle: 'پوستر و تصاویر',
|
editMediaCardTitle: 'پوستر و تصاویر',
|
||||||
editFaqCardTitle: 'سوالات متداول',
|
editFaqCardTitle: 'سوالات متداول',
|
||||||
reservedCapacityEditHint: 'ظرفیت رزرو شده: {count} نفر (مهمانهای خارج از قبیله). تغییر این عدد از صفحه مدیریت ایونت انجام میشود.',
|
reservedCapacityEditHint: 'ظرفیت رزرو شده: {count} نفر (مهمانهای خارج از قبیله). تغییر این عدد از صفحه مدیریت ایونت انجام میشود.',
|
||||||
@ -381,148 +109,15 @@ export const events = {
|
|||||||
revisionApprove: 'تایید و اعمال ویرایش',
|
revisionApprove: 'تایید و اعمال ویرایش',
|
||||||
revisionReject: 'رد ویرایش',
|
revisionReject: 'رد ویرایش',
|
||||||
revisionRejectReasonLabel: 'دلیل رد',
|
revisionRejectReasonLabel: 'دلیل رد',
|
||||||
revisionNoPending: 'ویرایش معلقی برای این رویداد نیست',
|
|
||||||
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود',
|
||||||
|
|
||||||
// Audience
|
|
||||||
genderOpen: 'آزاد برای عموم',
|
genderOpen: 'آزاد برای عموم',
|
||||||
genderFemaleOnly: 'خانمها',
|
genderFemaleOnly: 'خانمها',
|
||||||
genderMaleOnly: 'آقایان',
|
genderMaleOnly: 'آقایان',
|
||||||
age15_19: '۱۵-۱۹ سال',
|
age15_19: '۱۵-۱۹ سال',
|
||||||
age20_24: '۲۰-۲۴ سال',
|
age20_24: '۲۰-۲۴ سال',
|
||||||
age25Plus: '+۲۵',
|
age25Plus: '+۲۵',
|
||||||
|
|
||||||
// Manage dashboard
|
|
||||||
manageTitle: 'مدیریت ایونت',
|
|
||||||
manageLoadFailedTitle: 'دریافت اطلاعات مدیریتی ناموفق بود',
|
|
||||||
manageLoadFailedDescription: 'اطلاعات مدیریتی این ایونت در دسترس نیست یا شما مالک آن نیستید.',
|
|
||||||
shareLinkCopiedShort: 'لینک {channel} کپی شد',
|
|
||||||
reservedCapacityMin: 'ظرفیت رزرو شده باید صفر یا بیشتر باشد',
|
|
||||||
reservedCapacityUpdated: 'ظرفیت رزرو شده بهروز شد',
|
|
||||||
reservedCapacityUpdateFailed: 'تغییر ظرفیت رزرو شده ناموفق بود',
|
|
||||||
preview: 'پیشنمایش',
|
|
||||||
ariaCapacityFill: 'درصد تکمیل ظرفیت',
|
|
||||||
activeRegistrations: '{count} ثبتنام فعال',
|
|
||||||
remainingCapacity: '{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: 'این ایونت رایگان است',
|
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}٪ تخفیف',
|
percentOff: '{value}٪ تخفیف',
|
||||||
amountOff: '{value} تخفیف',
|
amountOff: '{value} تخفیف',
|
||||||
discountTypePercent: 'درصدی',
|
discountTypePercent: 'درصدی',
|
||||||
@ -572,10 +167,7 @@ export const events = {
|
|||||||
deactivated: 'غیرفعال شده',
|
deactivated: 'غیرفعال شده',
|
||||||
redeemedCodesTitle: 'کدهای استفادهشده ({count})',
|
redeemedCodesTitle: 'کدهای استفادهشده ({count})',
|
||||||
payableAmount: 'پرداختی: {amount}',
|
payableAmount: 'پرداختی: {amount}',
|
||||||
discountCodeInvalid: 'کد تخفیف معتبر نیست',
|
|
||||||
discountUpdateFailed: 'بهروزرسانی کد تخفیف ناموفق بود',
|
discountUpdateFailed: 'بهروزرسانی کد تخفیف ناموفق بود',
|
||||||
|
|
||||||
// Services
|
|
||||||
categoriesListFailed: 'دریافت لیست دستهبندیها ناموفق بود',
|
categoriesListFailed: 'دریافت لیست دستهبندیها ناموفق بود',
|
||||||
categoryCreateFailed: 'ایجاد دستهبندی ناموفق بود',
|
categoryCreateFailed: 'ایجاد دستهبندی ناموفق بود',
|
||||||
categoryUpdateFailed: 'ویرایش دستهبندی ناموفق بود',
|
categoryUpdateFailed: 'ویرایش دستهبندی ناموفق بود',
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
export const identity = {
|
|
||||||
title: 'احراز هویت',
|
|
||||||
gateTitle: 'احراز هویت برای ایجاد رویداد',
|
|
||||||
submitSuccess: 'درخواست احراز هویت شما ثبت شد و در حال بررسی است',
|
|
||||||
submitSuccessShort: 'درخواست احراز هویت شما ثبت شد',
|
|
||||||
otpSent: 'کد تأیید به شماره موبایل ثبتنامی شما ارسال شد',
|
|
||||||
alreadyVerifiedTitle: 'هویت شما قبلاً تأیید شده است',
|
|
||||||
alreadyVerifiedDescription: 'اکنون میتوانید رویداد جدید خود را ایجاد کنید.',
|
|
||||||
pendingTitle: 'احراز هویت شما در حال بررسی است',
|
|
||||||
pendingDescription: 'پس از بررسی نتیجه از طریق اعلانها به شما اطلاع داده میشود.',
|
|
||||||
pendingChip: 'در حال بررسی',
|
|
||||||
pendingBlockCreate: 'تا زمان تأیید احراز هویت، امکان ایجاد رویداد وجود ندارد.',
|
|
||||||
backToMyEvents: 'بازگشت به رویدادهای من',
|
|
||||||
rejectedFallback: 'درخواست احراز هویت شما رد شده است.',
|
|
||||||
resubmit: 'ارسال دوباره',
|
|
||||||
formSectionTitle: 'اطلاعات هویتی',
|
|
||||||
formSectionDescription: 'برای میزبانی رویداد، کد ملی خود را وارد کنید و قرارداد همکاری را بپذیرید.',
|
|
||||||
gateFormHint: 'پیش از شروع ساخت رویداد، احراز هویت را تکمیل کنید.',
|
|
||||||
nationalCode: 'کد ملی',
|
|
||||||
nationalCodeHint: 'کد ملی واردشده باید با شماره تلفن همراهی که با آن ثبتنام کردهاید برای یک شخص باشد.',
|
|
||||||
contractSectionTitle: 'قرارداد همکاری',
|
|
||||||
contractSectionDescription: 'متن کامل قرارداد را مطالعه کنید و در صورت موافقت، تأیید کنید.',
|
|
||||||
contractVersion: 'نسخه قرارداد: {version}',
|
|
||||||
contractCheckbox:
|
|
||||||
'قرارداد همکاری و حقالعملکاری قبیله را مطالعه کردم و با مفاد آن موافقم. تأیید میکنم که مسئولیت قانونی برگزاری و اجرای رویدادهای من بر عهده من است.',
|
|
||||||
acceptanceMetaNote:
|
|
||||||
'تاریخ پذیرش، نسخه قرارداد، شناسه برگزارکننده و شناسه پذیرش الکترونیکی پس از تأیید پیامکی بهصورت خودکار ثبت میشوند.',
|
|
||||||
sendOtp: 'دریافت کد تأیید',
|
|
||||||
otpSectionTitle: 'تأیید پیامکی',
|
|
||||||
otpSectionDescription: 'کد ارسالشده به شماره موبایل ثبتنامی خود را وارد کنید.',
|
|
||||||
otpCode: 'کد تأیید',
|
|
||||||
confirmOtp: 'تأیید و ارسال درخواست',
|
|
||||||
resendOtp: 'ارسال دوباره کد',
|
|
||||||
changeNationalCode: 'ویرایش کد ملی',
|
|
||||||
loadStatusFailed: 'بررسی وضعیت احراز هویت ناموفق بود.',
|
|
||||||
} as const
|
|
||||||
@ -1,34 +1,26 @@
|
|||||||
import { auth } from './auth'
|
import { auth } from './auth'
|
||||||
import { blog } from './blog'
|
import { blog } from './blog'
|
||||||
import { bookings } from './bookings'
|
|
||||||
import { chats } from './chats'
|
import { chats } from './chats'
|
||||||
import { common } from './common'
|
import { common } from './common'
|
||||||
import { discovery } from './discovery'
|
import { discovery } from './discovery'
|
||||||
import { errors } from './errors'
|
import { errors } from './errors'
|
||||||
import { events } from './events'
|
import { events } from './events'
|
||||||
import { identity } from './identity'
|
|
||||||
import { notifications } from './notifications'
|
|
||||||
import { profile } from './profile'
|
import { profile } from './profile'
|
||||||
import { reviews } from './reviews'
|
import { reviews } from './reviews'
|
||||||
import { seo } from './seo'
|
import { seo } from './seo'
|
||||||
import { support } from './support'
|
import { support } from './support'
|
||||||
import { status } from './status'
|
import { status } from './status'
|
||||||
import { validation } from './validation'
|
import { validation } from './validation'
|
||||||
import { wallet } from './wallet'
|
|
||||||
|
|
||||||
export const texts = {
|
export const texts = {
|
||||||
common,
|
common,
|
||||||
errors,
|
errors,
|
||||||
auth,
|
auth,
|
||||||
discovery,
|
discovery,
|
||||||
bookings,
|
|
||||||
chats,
|
chats,
|
||||||
events,
|
events,
|
||||||
profile,
|
profile,
|
||||||
wallet,
|
|
||||||
identity,
|
|
||||||
support,
|
support,
|
||||||
notifications,
|
|
||||||
reviews,
|
reviews,
|
||||||
blog,
|
blog,
|
||||||
seo,
|
seo,
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
export const notifications = {
|
|
||||||
title: 'اعلانها',
|
|
||||||
loadFailed: 'دریافت اعلانها ناموفق بود',
|
|
||||||
empty: 'هنوز اعلانی ندارید',
|
|
||||||
|
|
||||||
pushActive: 'اعلانهای Push روی این دستگاه فعال است',
|
|
||||||
pushBlockedPrefix: 'اعلانها قبلاً مسدود شدهاند. برای فعالسازی دوباره: ',
|
|
||||||
pushBlockedIos: 'به تنظیمات آیفون بروید: Settings ← قبیله ← Notifications ← Allow Notifications.',
|
|
||||||
pushBlockedBrowser: 'روی آیکون قفل یا تنظیمات کنار آدرس سایت در مرورگر بزنید، «اعلانها» را پیدا کنید و روی «اجازه» بگذارید.',
|
|
||||||
pushTitle: 'اعلانهای Push',
|
|
||||||
pushDescription: 'برای دریافت اعلان پیام و رزرو حتی وقتی اپ بسته است، فعال کنید.',
|
|
||||||
enable: 'فعالسازی',
|
|
||||||
|
|
||||||
installTitle: 'نصب اپلیکیشن قبیله',
|
|
||||||
installIosHint: 'برای نصب: در نوار پایین Safari روی دکمهی اشتراکگذاری بزنید، سپس «Add to Home Screen» را انتخاب کنید.',
|
|
||||||
installAndroidHint: 'برای دسترسی سریعتر و دریافت اعلانها، قبیله را روی گوشی خود نصب کنید.',
|
|
||||||
install: 'نصب',
|
|
||||||
later: 'بعداً',
|
|
||||||
updateReady: 'نسخه جدید قبیله آماده است.',
|
|
||||||
update: 'بهروزرسانی',
|
|
||||||
} as const
|
|
||||||
143
texts/profile.ts
143
texts/profile.ts
@ -1,147 +1,4 @@
|
|||||||
export const profile = {
|
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: 'سایر',
|
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: 'تکمیل پروفایل ناموفق بود',
|
completeProfileFailed: 'تکمیل پروفایل ناموفق بود',
|
||||||
fetchFollowStatusFailed: 'دریافت وضعیت دنبالکردن ناموفق بود',
|
|
||||||
followFailed: 'دنبالکردن میزبان ناموفق بود',
|
|
||||||
unfollowFailed: 'لغو دنبالکردن ناموفق بود',
|
|
||||||
updateNotifyFailed: 'بروزرسانی تنظیمات اعلان ناموفق بود',
|
|
||||||
fetchFollowingFailed: 'دریافت لیست دنبالشدهها ناموفق بود',
|
|
||||||
fetchFollowersFailed: 'دریافت لیست دنبالکنندگان ناموفق بود',
|
|
||||||
fetchContactLinksFailed: 'دریافت لینکهای تماس ناموفق بود',
|
|
||||||
addContactLinkFailed: 'افزودن لینک تماس ناموفق بود',
|
|
||||||
updateContactLinkFailed: 'بهروزرسانی لینک تماس ناموفق بود',
|
|
||||||
deleteContactLinkFailed: 'حذف لینک تماس ناموفق بود',
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@ -1,51 +1,8 @@
|
|||||||
export const reviews = {
|
export const reviews = {
|
||||||
// Shared
|
|
||||||
avgFromCount: '{rating} از {count} نظر',
|
|
||||||
loadMore: 'نمایش نظرات بیشتر',
|
loadMore: 'نمایش نظرات بیشتر',
|
||||||
loadMoreAlt: 'نمایش نظرهای بیشتر',
|
|
||||||
hostReply: 'پاسخ میزبان',
|
hostReply: 'پاسخ میزبان',
|
||||||
yourReply: 'پاسخ شما',
|
|
||||||
ratingOnly: 'فقط امتیاز ثبت شده است.',
|
ratingOnly: 'فقط امتیاز ثبت شده است.',
|
||||||
loading: 'در حال بارگذاری نظرات…',
|
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: 'وقتی مهمانان بعد از اتمام رویداد نظر بدهند، اینجا نمایش داده میشود.',
|
hostEmptyDescription: 'وقتی مهمانان بعد از اتمام رویداد نظر بدهند، اینجا نمایش داده میشود.',
|
||||||
hostEmptyTitle: 'هنوز نظری ثبت نشده',
|
hostEmptyTitle: 'هنوز نظری ثبت نشده',
|
||||||
editReply: 'ویرایش پاسخ',
|
|
||||||
saveReply: 'ذخیره پاسخ',
|
|
||||||
deleteReply: 'حذف پاسخ',
|
|
||||||
replyModalTitle: 'پاسخ به نظر',
|
|
||||||
replyBodyLabel: 'متن پاسخ',
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
367
texts/seo.ts
367
texts/seo.ts
@ -1,370 +1,3 @@
|
|||||||
export const seo = {
|
export const seo = {
|
||||||
titleTemplate: '%s | قبیله',
|
|
||||||
defaultDescription: 'پلتفرم رویدادها و تجربههای گروهی قبیله',
|
|
||||||
discoveryDescription: 'رویدادهای گروهی و تجربههای محلی را در قبیله پیدا کنید، رزرو کنید و شرکت کنید.',
|
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
|
} as const
|
||||||
|
|||||||
@ -26,7 +26,6 @@ export const status = {
|
|||||||
published,
|
published,
|
||||||
draft,
|
draft,
|
||||||
pending,
|
pending,
|
||||||
|
|
||||||
userRole: {
|
userRole: {
|
||||||
user,
|
user,
|
||||||
admin,
|
admin,
|
||||||
|
|||||||
@ -1,36 +1,13 @@
|
|||||||
import { common } from './common'
|
|
||||||
|
|
||||||
const open = 'باز'
|
const open = 'باز'
|
||||||
const closed = 'بسته'
|
const closed = 'بسته'
|
||||||
|
|
||||||
export const support = {
|
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: {
|
categories: {
|
||||||
general: common.general,
|
general: 'عمومی',
|
||||||
account: common.account,
|
account: 'حساب کاربری',
|
||||||
event: 'رویداد',
|
event: 'رویداد',
|
||||||
booking: 'رزرو',
|
booking: 'رزرو',
|
||||||
payment: common.payment,
|
payment: 'پرداخت',
|
||||||
technical: 'مشکل فنی',
|
technical: 'مشکل فنی',
|
||||||
},
|
},
|
||||||
priorities: {
|
priorities: {
|
||||||
|
|||||||
@ -18,15 +18,6 @@ export const validation = {
|
|||||||
otpDigits: 'کد باید ۴ رقم باشد',
|
otpDigits: 'کد باید ۴ رقم باشد',
|
||||||
birthDateRange: 'تاریخ تولد باید بین سالهای ۱۳۳۰ تا ۱۳۹۵ باشد',
|
birthDateRange: 'تاریخ تولد باید بین سالهای ۱۳۳۰ تا ۱۳۹۵ باشد',
|
||||||
},
|
},
|
||||||
profile: {
|
|
||||||
bioMax: 'بیوگرافی حداکثر ۲۰۰۰ کاراکتر است',
|
|
||||||
addressMax: 'آدرس حداکثر ۵۰۰ کاراکتر است',
|
|
||||||
},
|
|
||||||
identity: {
|
|
||||||
contractRequired: 'پذیرش قرارداد همکاری الزامی است',
|
|
||||||
otpRequired: 'کد تأیید را وارد کنید',
|
|
||||||
otpDigits: 'کد تأیید باید ۴ رقم باشد',
|
|
||||||
},
|
|
||||||
withdrawal: {
|
withdrawal: {
|
||||||
accountHolderRequired: 'نام صاحب حساب را وارد کنید',
|
accountHolderRequired: 'نام صاحب حساب را وارد کنید',
|
||||||
accountHolderMax: 'نام صاحب حساب حداکثر ۲۰۰ کاراکتر است',
|
accountHolderMax: 'نام صاحب حساب حداکثر ۲۰۰ کاراکتر است',
|
||||||
@ -38,36 +29,8 @@ export const validation = {
|
|||||||
amountPositive: 'مبلغ باید بیشتر از صفر باشد',
|
amountPositive: 'مبلغ باید بیشتر از صفر باشد',
|
||||||
},
|
},
|
||||||
eventWizard: {
|
eventWizard: {
|
||||||
titleRequired: 'عنوان رویداد الزامی است',
|
|
||||||
titleMax: 'عنوان حداکثر ۲۰۰ کاراکتر است',
|
|
||||||
slugRequired: 'اسلاگ الزامی است',
|
|
||||||
slugMax: 'اسلاگ حداکثر ۲۲۰ کاراکتر است',
|
|
||||||
slugPattern: 'اسلاگ فقط میتواند شامل حروف کوچک انگلیسی، عدد و خط تیره باشد',
|
|
||||||
slugReserved: 'این اسلاگ برای مسیرهای سیستمی رزرو شده است',
|
|
||||||
categoryRequired: 'دستهبندی را انتخاب کنید',
|
|
||||||
shortDescriptionMax: 'توضیح کوتاه حداکثر ۵۰۰ کاراکتر است',
|
|
||||||
startDateRequired: 'تاریخ شروع الزامی است',
|
|
||||||
startTimeRequired: 'ساعت شروع الزامی است',
|
|
||||||
endDateRequired: 'تاریخ پایان الزامی است',
|
|
||||||
endTimeRequired: 'ساعت پایان الزامی است',
|
|
||||||
addressRequired: 'آدرس الزامی است',
|
|
||||||
startEndRequired: 'تاریخ و ساعت شروع و پایان الزامی است',
|
|
||||||
endAfterStart: 'زمان پایان باید بعد از زمان شروع باشد',
|
endAfterStart: 'زمان پایان باید بعد از زمان شروع باشد',
|
||||||
generalAreaRequired: 'برای نمایش «فقط برای رزروکنندگان»، منطقه کلی را وارد کنید',
|
|
||||||
priceRequired: 'قیمت را وارد کنید',
|
|
||||||
priceNonNegative: 'قیمت نمیتواند منفی باشد',
|
|
||||||
capacityRequired: 'ظرفیت را وارد کنید',
|
|
||||||
capacityInteger: 'ظرفیت باید عدد صحیح باشد',
|
|
||||||
capacityMin: 'ظرفیت باید حداقل ۱ باشد',
|
|
||||||
reservedCapacityRequired: 'ظرفیت رزرو شده را وارد کنید',
|
|
||||||
reservedCapacityInteger: 'ظرفیت رزرو شده باید عدد صحیح باشد',
|
|
||||||
reservedCapacityNonNegative: 'ظرفیت رزرو شده نمیتواند منفی باشد',
|
|
||||||
cancelPenaltyRequired: 'درصد جریمه را وارد کنید',
|
|
||||||
cancelPenaltyNonNegative: 'درصد جریمه نمیتواند منفی باشد',
|
|
||||||
cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است',
|
|
||||||
cancelPenaltyOrder: 'درصد جریمه باید با نزدیکشدن به زمان رویداد بیشتر یا مساوی شود',
|
cancelPenaltyOrder: 'درصد جریمه باید با نزدیکشدن به زمان رویداد بیشتر یا مساوی شود',
|
||||||
paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد',
|
|
||||||
reservedExceedsCapacity: 'ظرفیت رزرو شده نمیتواند از ظرفیت کل بیشتر باشد',
|
|
||||||
faqQuestionRequired: 'سوال را وارد کنید',
|
faqQuestionRequired: 'سوال را وارد کنید',
|
||||||
faqAnswerRequired: 'پاسخ را وارد کنید',
|
faqAnswerRequired: 'پاسخ را وارد کنید',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
export const wallet = {
|
|
||||||
title: 'کیف پول',
|
|
||||||
balanceLabel: 'موجودی: {amount}',
|
|
||||||
balance: 'موجودی کیف پول',
|
|
||||||
useBalance: 'پرداختی از کیف پول',
|
|
||||||
requestWithdrawal: 'درخواست برداشت',
|
|
||||||
sectionsAria: 'بخشهای کیف پول',
|
|
||||||
tabAccounts: 'حسابها',
|
|
||||||
tabWithdrawals: 'برداشتها',
|
|
||||||
tabTransactions: 'تراکنشها',
|
|
||||||
tabSettlements: 'تسویههای میزبانی',
|
|
||||||
addAccount: 'افزودن حساب',
|
|
||||||
editAccount: 'ویرایش حساب',
|
|
||||||
noBankAccounts: 'هنوز حساب بانکی ثبت نکردهاید',
|
|
||||||
noWithdrawals: 'درخواست برداشتی ثبت نشده است',
|
|
||||||
noTransactions: 'تراکنشی ثبت نشده است',
|
|
||||||
noSettlements: 'هنوز تسویهای برای شما ثبت نشده است',
|
|
||||||
trackingCodeLabel: 'شماره پیگیری:',
|
|
||||||
viewPaymentReceipt: 'مشاهده رسید پرداخت',
|
|
||||||
|
|
||||||
addBankAccountTitle: 'افزودن حساب بانکی',
|
|
||||||
editBankAccountTitle: 'ویرایش حساب بانکی',
|
|
||||||
nationalCodeHint: 'کد ملی برای استعلام شبا لازم است. پس از تأیید احراز هویت میزبان، از روی پرونده هویت خوانده میشود.',
|
|
||||||
withdrawalNeedsBankAccount: 'ابتدا یک حساب بانکی ثبت کنید؛ پس از تأیید حساب میتوانید درخواست برداشت بدهید',
|
|
||||||
withdrawalNeedsApprovedBankAccount: 'حساب بانکی شما هنوز تأیید نشده است؛ پس از تأیید ادمین میتوانید درخواست برداشت بدهید',
|
|
||||||
submitAccount: 'ثبت حساب',
|
|
||||||
saveAccount: 'ذخیره تغییرات',
|
|
||||||
iban: 'شماره شبا',
|
|
||||||
|
|
||||||
submitRequest: 'ثبت درخواست',
|
|
||||||
bankAccount: 'حساب بانکی',
|
|
||||||
bankAccountOption: '{iban}',
|
|
||||||
maxAmount: 'حداکثر: {amount}',
|
|
||||||
amountToman: 'مبلغ (تومان)',
|
|
||||||
|
|
||||||
txRefundGuestCancel: 'بازگشت وجه لغو رزرو',
|
|
||||||
txRefundEventCancel: 'بازگشت وجه لغو رویداد',
|
|
||||||
txWithdrawal: 'برداشت',
|
|
||||||
txDeposit: 'واریز',
|
|
||||||
|
|
||||||
bankAccountCreated: 'حساب بانکی ثبت شد',
|
|
||||||
bankAccountUpdated: 'حساب بانکی بهروز شد و دوباره در صف بررسی قرار گرفت',
|
|
||||||
bankAccountCreateFailed: 'ثبت حساب بانکی ناموفق بود',
|
|
||||||
addBankAccountFirst: 'ابتدا یک حساب بانکی اضافه کنید',
|
|
||||||
withdrawalCreated: 'درخواست برداشت ثبت شد',
|
|
||||||
withdrawalCreateFailed: 'ثبت درخواست برداشت ناموفق بود',
|
|
||||||
|
|
||||||
fetchWalletFailed: 'دریافت کیف پول ناموفق بود',
|
|
||||||
fetchTransactionsFailed: 'دریافت تراکنشهای کیف پول ناموفق بود',
|
|
||||||
paymentFailed: 'ثبت پرداخت ناموفق بود',
|
|
||||||
|
|
||||||
topUp: 'شارژ کیف پول',
|
|
||||||
topUpSubmit: 'پرداخت و شارژ',
|
|
||||||
topUpAmount: 'مبلغ شارژ (تومان)',
|
|
||||||
topUpMinHint: 'حداقل {amount}',
|
|
||||||
topUpCreated: 'درگاه پرداخت باز شد؛ پس از پرداخت موجودی بهروز میشود.',
|
|
||||||
topUpFailed: 'شروع شارژ کیف پول ناموفق بود',
|
|
||||||
topUpAwaiting: 'در انتظار تأیید پرداخت…',
|
|
||||||
topUpSuccess: 'کیف پول با موفقیت شارژ شد',
|
|
||||||
topUpSimulatedNote: 'درگاه پرداخت هنوز به سامانه متصل نشده است؛ این شارژ بهصورت آزمایشی انجام شد.',
|
|
||||||
|
|
||||||
paymentReturnTitle: 'نتیجه پرداخت',
|
|
||||||
paymentReturnSuccess: 'پرداخت با موفقیت انجام شد.',
|
|
||||||
paymentReturnFailed: 'پرداخت ناموفق بود یا لغو شد.',
|
|
||||||
paymentReturnPending: 'در حال بررسی وضعیت پرداخت…',
|
|
||||||
paymentReturnPendingHint: 'اگر چند لحظه پیش در درگاه پرداخت کردید، همین صفحه بهزودی بهروز میشود.',
|
|
||||||
paymentReturnInvalid: 'لینک بازگشت از درگاه نامعتبر است.',
|
|
||||||
paymentReturnGoWallet: 'رفتن به کیف پول',
|
|
||||||
paymentReturnGoBooking: 'بازگشت به رزرو',
|
|
||||||
paymentReturnGoHome: 'صفحه اصلی',
|
|
||||||
} as const
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
import { texts } from '@/texts'
|
|
||||||
|
|
||||||
const v = texts.validation.eventWizard
|
|
||||||
|
|
||||||
function toDateTime(dateIso: string, minutes: number): Date | null {
|
|
||||||
const date = new Date(dateIso)
|
|
||||||
|
|
||||||
if (!Number.isFinite(date.getTime()) || !Number.isFinite(minutes)) return null
|
|
||||||
|
|
||||||
date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0)
|
|
||||||
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConsumerEventWizardStep2Values {
|
|
||||||
startDate: string
|
|
||||||
startTime: number
|
|
||||||
endDate: string
|
|
||||||
endTime: number
|
|
||||||
address: string
|
|
||||||
addressVisibility: 'public' | 'attendees_only'
|
|
||||||
generalArea?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ConsumerEventWizardStep2FieldErrors = Partial<Record<keyof ConsumerEventWizardStep2Values, string>>
|
|
||||||
|
|
||||||
export const ConsumerEventWizardStep2Validation = z
|
|
||||||
.object({
|
|
||||||
startDate: z.string().min(1, v.startDateRequired),
|
|
||||||
startTime: z.number({ error: v.startTimeRequired }),
|
|
||||||
endDate: z.string().min(1, v.endDateRequired),
|
|
||||||
endTime: z.number({ error: v.endTimeRequired }),
|
|
||||||
address: z.string().trim().min(1, v.addressRequired),
|
|
||||||
addressVisibility: z.enum(['public', 'attendees_only']),
|
|
||||||
generalArea: z.string().trim().optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
.superRefine((values, ctx) => {
|
|
||||||
const startsAt = toDateTime(values.startDate, values.startTime)
|
|
||||||
const endsAt = toDateTime(values.endDate, values.endTime)
|
|
||||||
|
|
||||||
if (!startsAt || !endsAt) {
|
|
||||||
ctx.addIssue({ code: 'custom', message: v.startEndRequired, path: ['startDate'] })
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endsAt <= startsAt) {
|
|
||||||
ctx.addIssue({ code: 'custom', message: v.endAfterStart, path: ['endDate'] })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.addressVisibility === 'attendees_only' && !values.generalArea?.trim()) {
|
|
||||||
ctx.addIssue({ code: 'custom', message: v.generalAreaRequired, path: ['generalArea'] })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export function parseConsumerEventWizardStep2(
|
|
||||||
payload: ConsumerEventWizardStep2Values
|
|
||||||
): { success: true; data: ConsumerEventWizardStep2Values } | { success: false; fieldErrors: ConsumerEventWizardStep2FieldErrors } {
|
|
||||||
const result = ConsumerEventWizardStep2Validation.safeParse(payload)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
startDate: result.data.startDate,
|
|
||||||
startTime: result.data.startTime,
|
|
||||||
endDate: result.data.endDate,
|
|
||||||
endTime: result.data.endTime,
|
|
||||||
address: result.data.address,
|
|
||||||
addressVisibility: result.data.addressVisibility,
|
|
||||||
generalArea: result.data.generalArea,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldErrors: ConsumerEventWizardStep2FieldErrors = {}
|
|
||||||
|
|
||||||
for (const issue of result.error.issues) {
|
|
||||||
const key = issue.path[0]
|
|
||||||
|
|
||||||
if (typeof key === 'string' && !(key in fieldErrors)) {
|
|
||||||
fieldErrors[key as keyof ConsumerEventWizardStep2Values] = issue.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, fieldErrors }
|
|
||||||
}
|
|
||||||
@ -1,227 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import {
|
import { EventWizardFaqFormValidation } from '@/validation/eventWizard'
|
||||||
ConsumerEventWizardStep2Validation,
|
|
||||||
EventWizardFaqFormValidation,
|
|
||||||
EventWizardStep1Validation,
|
|
||||||
EventWizardStep2Validation,
|
|
||||||
EventWizardStep3Validation,
|
|
||||||
} from '@/validation/eventWizard'
|
|
||||||
|
|
||||||
describe('event wizard validation', () => {
|
describe('EventWizardFaqFormValidation', () => {
|
||||||
it('requires title, slug, and category on step 1', () => {
|
it('requires question and answer', () => {
|
||||||
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', () => {
|
|
||||||
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
|
expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false)
|
||||||
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({
|
expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({
|
||||||
question: 'سوال؟',
|
question: 'سوال؟',
|
||||||
|
|||||||
@ -1,155 +1,9 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { EVENT_SLUG_MAX_LENGTH } from '@/features/events/eventSlug'
|
|
||||||
import { isPublicEventSlug } from '@/lib/routing/eventRoute'
|
|
||||||
import { texts } from '@/texts'
|
import { texts } from '@/texts'
|
||||||
|
|
||||||
// همتراز با اسلاگ دستهبندیها — فقط لاتین، بدون تبدیل ارقام فارسی
|
|
||||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
||||||
const v = texts.validation.eventWizard
|
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({
|
export const EventWizardFaqFormValidation = z.object({
|
||||||
question: z.string().trim().min(1, v.faqQuestionRequired),
|
question: z.string().trim().min(1, v.faqQuestionRequired),
|
||||||
answer: z.string().trim().min(1, v.faqAnswerRequired),
|
answer: z.string().trim().min(1, v.faqAnswerRequired),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user