diff --git a/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx b/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx index fd4df50..341aad6 100644 --- a/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx +++ b/app/(dashboard)/blog-articles/_components/ArticleFormModal.tsx @@ -17,7 +17,7 @@ import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator' import useAdminCrudFormModal from '@/hooks/useAdminCrudFormModal' import { type BlogArticle, CREATE_ARTICLE, UPDATE_ARTICLE } from '@/services/blogArticles' import { fetchAllCities, type City } from '@/services/geography' -import { LIST_PUBLIC_CATEGORIES } from '@/services/eventCategories' +import { LIST_ADMIN_CATEGORIES_FLAT } from '@/services/eventCategories' import { analyzeArticleBody, ARTICLE_EXCERPT_MAX, @@ -246,7 +246,7 @@ const ArticleFormModal = ({ isOpen, onOpenChange, article, onSuccess }: ArticleF .then(setCities) .catch(() => addToast({ title: 'بارگذاری فهرست شهرها ناموفق بود', color: 'danger' })) - void LIST_PUBLIC_CATEGORIES().then((result) => { + void LIST_ADMIN_CATEGORIES_FLAT().then((result) => { if (result.ok) setEventCategories(result.data.items) }) }, []) diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index d5b56fe..c53d9ac 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -18,7 +18,6 @@ import BookmarkIcon from '@/components/icons/BookmarkIcon' import UserIdCardIcon from '@/components/icons/UserIdCardIcon' import CoinDollarIcon from '@/components/icons/CoinDollarIcon' import AngleLeftIcon from '@/components/icons/AngleLeftIcon' -import PlusIcon from '@/components/icons/PlusIcon' interface StatItem { label: string @@ -237,13 +236,6 @@ const DashboardPage = () => { مهم‌ترین شاخص‌های کاربران، رویدادها، رزروها و عملیات مالی را در یک نگاه بررسی کنید.

- - - ایجاد رویداد - {
diff --git a/app/(dashboard)/manage-events/new/layout.tsx b/app/(dashboard)/manage-events/new/layout.tsx deleted file mode 100644 index 16ea666..0000000 --- a/app/(dashboard)/manage-events/new/layout.tsx +++ /dev/null @@ -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 -} diff --git a/app/(dashboard)/manage-events/new/page.tsx b/app/(dashboard)/manage-events/new/page.tsx deleted file mode 100644 index e6a7dd8..0000000 --- a/app/(dashboard)/manage-events/new/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import AdminEventCreate from '@/features/events/create/AdminEventCreate' - -const AdminEventCreatePage = () => - -export default AdminEventCreatePage diff --git a/app/(dashboard)/manage-events/page.tsx b/app/(dashboard)/manage-events/page.tsx index 5f1286a..0f80527 100644 --- a/app/(dashboard)/manage-events/page.tsx +++ b/app/(dashboard)/manage-events/page.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from 'react' import type { PaginationListColumnType } from '@/types' -import Button from '@/components/formElements/Button' import PaginatedList from '@/components/PaginatedList' import PageNavbar from '@/components/layouts/PageNavbar' import AdminTableViewButton from '@/components/ui/AdminTableViewButton' @@ -183,17 +182,7 @@ const EventsPage = () => { return (
- - افزودن رویداد - - } - pageTitle="رویدادها" - /> +
({ id: String(city.id), name: city.name })) const title = flow.step === 'otp' ? texts.auth.otpTitle : flow.step === 'profile' ? texts.auth.profileTitle : null diff --git a/components/events/EventGuestListAccessPanel.tsx b/components/events/EventGuestListAccessPanel.tsx index 781b396..399ff86 100644 --- a/components/events/EventGuestListAccessPanel.tsx +++ b/components/events/EventGuestListAccessPanel.tsx @@ -8,19 +8,16 @@ import { formatIranianMobile, formatPersianDate } from '@/lib/formatters' import { fetchEventGuestListLinks, fetchEventInvitedGuests, - type EventGuestListAccessMode, type EventGuestListLink, type EventInvitedGuest, } from '@/services/eventGuestListAccess' import { format, texts } from '@/texts' interface EventGuestListAccessPanelProps { - accessMode: EventGuestListAccessMode eventId: string - surface?: 'admin' | 'consumer' } -const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' }: EventGuestListAccessPanelProps) => { +const EventGuestListAccessPanel = ({ eventId }: EventGuestListAccessPanelProps) => { const [links, setLinks] = useState([]) const [invitedGuests, setInvitedGuests] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -34,8 +31,8 @@ const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' } setIsLoading(true) setError(null) const [nextLinks, nextInvitedGuests] = await Promise.all([ - fetchEventGuestListLinks(eventId, accessMode), - fetchEventInvitedGuests(eventId, accessMode), + fetchEventGuestListLinks(eventId), + fetchEventInvitedGuests(eventId), ]) if (!cancelled) { @@ -54,12 +51,10 @@ const EventGuestListAccessPanel = ({ accessMode, eventId, surface = 'consumer' } return () => { cancelled = true } - }, [accessMode, eventId]) - - const surfaceClass = surface === 'admin' ? 'admin-surface' : 'consumer-surface' + }, [eventId]) return ( - + {texts.events.guestAccessTitle} diff --git a/components/events/create/AudienceRestrictionSelector.tsx b/components/events/create/AudienceRestrictionSelector.tsx deleted file mode 100644 index 66334fc..0000000 --- a/components/events/create/AudienceRestrictionSelector.tsx +++ /dev/null @@ -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 ( -
- { - onGenderRestrictionChange(value as EventGenderRestriction) - }} - /> - { - onAgeRestrictionChange(value as EventAgeRestriction) - }} - /> -
- ) -} - -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 ( -
-
- {options.map((option) => { - const active = option.selectKey === value - - return ( - - ) - })} -
-
- ) -} diff --git a/components/events/create/CancellationPolicyEditor.tsx b/components/events/create/CancellationPolicyEditor.tsx deleted file mode 100644 index 254c0f3..0000000 --- a/components/events/create/CancellationPolicyEditor.tsx +++ /dev/null @@ -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 ( -
- {tiers.map((tier) => { - const normalizedValue = Number.isFinite(tier.value) ? Math.min(100, Math.max(0, tier.value)) : 0 - - return ( -
-
-

{tier.label}

-
- { - const next = Number(nextValue) - - onChange(tier.field, Number.isFinite(next) ? Math.min(100, Math.max(0, next)) : 0) - }} - /> - ٪ -
-
-
- ) - })} -
- ) -} diff --git a/components/events/create/CreateEventPreviewCard.tsx b/components/events/create/CreateEventPreviewCard.tsx deleted file mode 100644 index a42eb60..0000000 --- a/components/events/create/CreateEventPreviewCard.tsx +++ /dev/null @@ -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 ( -
-
-
-

{categoryName || texts.events.eventCategoryFallback}

-

{data.title || texts.events.eventTitleLabel}

- {data.shortDescription ?

{data.shortDescription}

: null} -
-
- -
- {fields.map((field) => ( -
-

{field.label}

-

{field.value}

-
- ))} -
-
- ) -} diff --git a/components/events/create/EventCreateWizard.tsx b/components/events/create/EventCreateWizard.tsx deleted file mode 100644 index d1dffac..0000000 --- a/components/events/create/EventCreateWizard.tsx +++ /dev/null @@ -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: () => , -}) -const Step2ScheduleLocation = dynamic(() => import('@/components/events/create/steps/Step2ScheduleLocation'), { - ssr: false, - loading: () => , -}) -const Step3PricingCapacity = dynamic(() => import('@/components/events/create/steps/Step3PricingCapacity'), { - ssr: false, - loading: () => , -}) -const Step4FaqPreview = dynamic(() => import('@/components/events/create/steps/Step4FaqPreview'), { - ssr: false, - loading: () => , -}) -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(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(null) - const [previousAttendees, setPreviousAttendees] = useState([]) - 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([]) - - 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(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) => { - 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 - - return ( -
-
- - {wasCloneLoaded - ? texts.events.wizardCloneHint - : wasDraftRestored - ? texts.events.wizardDraftRestoredHint - : texts.events.wizardAutosaveHint} - - -
- - -
- {currentStepTitle ?

{currentStepTitle}

: null} - {step === 1 && ( - { - patchData(patch) - setStep(2) - }} - /> - )} - - {step === 2 && ( - { - setStep(1) - }} - onChange={patchData} - onNext={(patch) => { - patchData(patch) - setStep(3) - }} - /> - )} - - {step === 3 && ( - { - setStep(2) - }} - onChange={patchData} - onNext={(patch) => { - patchData(patch) - setStep(4) - }} - /> - )} - - {step === 4 && ( - { - setStep(3) - }} - onChange={patchData} - onSaveDraft={() => void handleSaveDraft()} - /> - )} -
- - {createdEventId ? ( - { - router.push(finishRoute) - }} - onOpenChange={setShowPostCreateInvite} - /> - ) : null} -
- ) -} diff --git a/components/events/create/TicketTypeSelector.tsx b/components/events/create/TicketTypeSelector.tsx deleted file mode 100644 index 5f2dca9..0000000 --- a/components/events/create/TicketTypeSelector.tsx +++ /dev/null @@ -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 ( - { - if (disabled) return - onChange(key === 'free') - }} - > - {/* HeroUI tab default is h-8; list has p-1 → use !h-[46px] so total track = 54px */} - - - {TICKET_TYPE_TABS.map(([tab, label]) => ( - - {label} - - - ))} - - - - ) -} diff --git a/components/events/create/WizardStepper.tsx b/components/events/create/WizardStepper.tsx deleted file mode 100644 index 189d49f..0000000 --- a/components/events/create/WizardStepper.tsx +++ /dev/null @@ -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 ( -
- {isDone && !isActive ? ( - - - - ) : ( - step - )} -
- ) -} - -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 ( - - ) -} diff --git a/components/events/create/cloneFromEvent.test.ts b/components/events/create/cloneFromEvent.test.ts deleted file mode 100644 index a908197..0000000 --- a/components/events/create/cloneFromEvent.test.ts +++ /dev/null @@ -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: '

توضیح

', - 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: 'شنبه' }]) - }) -}) diff --git a/components/events/create/cloneFromEvent.ts b/components/events/create/cloneFromEvent.ts deleted file mode 100644 index a4548e1..0000000 --- a/components/events/create/cloneFromEvent.ts +++ /dev/null @@ -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, - })), - } -} diff --git a/components/events/create/steps/Step1BasicInfo.tsx b/components/events/create/steps/Step1BasicInfo.tsx deleted file mode 100644 index 3a7ca2b..0000000 --- a/components/events/create/steps/Step1BasicInfo.tsx +++ /dev/null @@ -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) => void - onNext: (patch: Partial) => void -} - -export default function Step1BasicInfo({ data, onChange, onNext }: Step1BasicInfoProps) { - const categoriesQuery = usePublicCategoriesQuery() - const categories = categoriesQuery.data?.items ?? [] - const form = useForm({ - 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 ( - -
- - پوستر و تصاویر - - - { - onChange({ media }) - }} - /> - - - معرفی رویداد - - - - - - - - - -

{texts.events.fullDescriptionLabel}

- { - form.setValue('description', value) - }} - /> -
- - -
- - {texts.events.configureEventLink} - {texts.events.advanced} - -
- -
-
-
- - -
-
-
- ) -} diff --git a/components/events/create/steps/Step2ScheduleLocation.tsx b/components/events/create/steps/Step2ScheduleLocation.tsx deleted file mode 100644 index e45ef5d..0000000 --- a/components/events/create/steps/Step2ScheduleLocation.tsx +++ /dev/null @@ -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) => void - onNext: (patch: Partial) => void -} - -export default function Step2ScheduleLocation({ data, onBack, onChange, onNext }: Step2ScheduleLocationProps) { - const [cities, setCities] = useState([]) - - const form = useForm({ - 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 ( - -
- - {texts.events.scheduleSectionTitle} - - - - - - - {texts.events.venueSectionTitle} - - {/* انتخاب استان فعلاً غیرفعال است؛ استان از روی شهر تعیین می‌شود. - */} - - - - -

{texts.events.addressHostContactHint}

-
- - - { - form.setValue('address', address, { shouldDirty: true, shouldTouch: true }) - onChange({ address }) - }} - onChange={({ lat, lng }) => { - hasManualLocationRef.current = true - onChange({ lat, lng }) - }} - /> - - - - {texts.events.addressVisibilitySectionTitle} - - - - - - - {addressVisibility === 'attendees_only' && ( - - - - )} - - -
-
-
- ) -} diff --git a/components/events/create/steps/Step3PricingCapacity.tsx b/components/events/create/steps/Step3PricingCapacity.tsx deleted file mode 100644 index d41ad15..0000000 --- a/components/events/create/steps/Step3PricingCapacity.tsx +++ /dev/null @@ -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) => void - onNext: (patch: Partial) => void -} - -export default function Step3PricingCapacity({ data, onBack, onChange, onNext }: Step3PricingCapacityProps) { - const form = useForm({ - 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 ( - -
- - - {texts.events.ticketTypeSectionTitle} - - - - { - form.setValue('isFree', nextIsFree, { shouldDirty: true, shouldValidate: true }) - }} - /> - - - {!isFree ? ( - - ) : null} - - - - -

{texts.events.reservedCapacityWizardHint}

-
- - {texts.events.audienceSectionTitle} - - - { - form.setValue('ageRestriction', ageRestriction, { shouldDirty: true, shouldValidate: true }) - }} - onGenderRestrictionChange={(genderRestriction) => { - form.setValue('genderRestriction', genderRestriction, { shouldDirty: true, shouldValidate: true }) - }} - /> - - - - {texts.events.cancellationSectionTitle} - - - - { - form.setValue(field, value, { shouldDirty: true, shouldValidate: true }) - }} - /> - - - -
- - {texts.events.moreSettings} - {texts.events.advancedSettingsHint} - -
- - - - -
-
-
- - -
-
-
- ) -} diff --git a/components/events/create/steps/Step4FaqPreview.tsx b/components/events/create/steps/Step4FaqPreview.tsx deleted file mode 100644 index b7da1db..0000000 --- a/components/events/create/steps/Step4FaqPreview.tsx +++ /dev/null @@ -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) => 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 ( - - {texts.events.previewEventTitle} - - - - - - -
-

{texts.events.readyToSave}

-
    -
  • ✓ {texts.events.checklistIntroDone}
  • -
  • ✓ {texts.events.checklistScheduleDone}
  • -
  • ✓ {texts.events.checklistPricingDone}
  • -
  • ✓ {texts.events.checklistPosterDone}
  • -
-
-
- - {texts.events.faqs} - - - { - onChange({ faqs }) - }} - /> - - - -
- ) -} diff --git a/components/events/create/steps/Step5GuestList.tsx b/components/events/create/steps/Step5GuestList.tsx deleted file mode 100644 index d905451..0000000 --- a/components/events/create/steps/Step5GuestList.tsx +++ /dev/null @@ -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([]) - 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 ( - void handleSendInvitations()} - onClose={onFinish} - onOpenChange={onOpenChange} - > -
-

{texts.events.invitePreviousGuestsDescription}

- - } - name="guestSearch" - placeholder={texts.events.guestSearchPlaceholder} - value={search} - onClear={() => { - setSearch('') - }} - onValueChange={(next) => { - setSearch(coerceToString(next)) - }} - /> - - { - toggleSelectAll(Boolean(checked)) - }} - /> - -
- {filteredAttendees.length === 0 ? ( -

{texts.common.emptyChoice}

- ) : ( - filteredAttendees.map((attendee) => ( -
- { - toggleAttendee(attendee.userId, Boolean(checked)) - }} - /> -
- )) - )} -
-
-
- ) -} - -export async function loadPreviousAttendeesForStep5(): Promise { - try { - return await fetchPreviousAttendees() - } catch { - return [] - } -} diff --git a/components/events/create/types.ts b/components/events/create/types.ts index 38ca830..714a5d6 100644 --- a/components/events/create/types.ts +++ b/components/events/create/types.ts @@ -1,7 +1,4 @@ -import { formatNumber } from '@/helpers' -import { formatPersianDate } from '@/lib/formatters' -import type { EventAgeRestriction, EventGenderRestriction } from '@/lib/eventAudience' -import { texts, format } from '@/texts' +import { texts } from '@/texts' export interface StagedMediaItem { id: string @@ -21,85 +18,6 @@ export interface FaqItem { answer: string } -export interface EventWizardFormData { - title: string - slug: string - categoryId: string - shortDescription: string - description: string - media: StagedMediaItem[] - startDate: string - startTime: number - endDate: string - endTime: number - provinceId: string - cityId: string - address: string - lat: number - lng: number - isFree: boolean - price: number - capacity: number - reservedCapacity: number - genderRestriction: EventGenderRestriction - ageRestriction: EventAgeRestriction - cancellationFeePercent: number - cancellationFeePercent12To24Hours: number - cancellationFeePercentMoreThan24Hours: number - isDiscoverable: boolean - autoCreateGroup: boolean - addressVisibility: 'public' | 'attendees_only' - generalArea: string - waitlistAutoOffer: boolean - sendReviewRequestSms: boolean - faqs: FaqItem[] -} - -export const INITIAL_WIZARD_DATA: EventWizardFormData = { - title: '', - slug: '', - categoryId: '', - shortDescription: '', - description: '', - media: [], - startDate: '', - startTime: 600, - endDate: '', - endTime: 720, - provinceId: '', - cityId: '', - address: '', - lat: 35.6892, - lng: 51.389, - isFree: false, - price: 0, - capacity: 20, - reservedCapacity: 0, - genderRestriction: 'open', - ageRestriction: 'open', - cancellationFeePercent: 30, - cancellationFeePercent12To24Hours: 20, - cancellationFeePercentMoreThan24Hours: 10, - // Defaults to true here in the wizard, unlike the backend's own - // default of false -- since this is the only place that currently - // sets it, and an organizer/admin creating an event almost always - // wants it visible once published. Explicitly overridable below. - isDiscoverable: true, - autoCreateGroup: true, - addressVisibility: 'public', - generalArea: '', - waitlistAutoOffer: true, - sendReviewRequestSms: true, - faqs: [], -} - -export const WIZARD_STEPS = [ - { key: 1, label: texts.events.wizardStep1Label }, - { key: 2, label: texts.events.wizardStep2Label }, - { key: 3, label: texts.events.wizardStep3Label }, - { key: 4, label: texts.events.wizardStep4Label }, -] as const - export function combineDateAndMinutes(dateIso: string, minutes: number): string { const date = new Date(dateIso) @@ -114,18 +32,3 @@ export function combineDateAndMinutes(dateIso: string, minutes: number): string return date.toISOString() } - -export function formatWizardDate(value: string): string { - return formatPersianDate(value) -} - -export function formatToman(value: number): string { - return format(texts.events.formatToman, { value: formatNumber(value) }) -} - -export function formatMinutesAsTime(minutes: number): string { - const hours = Math.floor(minutes / 60) - const mins = minutes % 60 - - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}` -} diff --git a/constants/routes.test.ts b/constants/routes.test.ts index 62a27ed..1413adf 100644 --- a/constants/routes.test.ts +++ b/constants/routes.test.ts @@ -5,7 +5,6 @@ import { APP_ROUTES, CONSUMER_ROUTES, SEO_ROUTES } from '@/constants/routes' describe('event route ownership', () => { it('keeps admin event workflows under /manage-events', () => { expect(APP_ROUTES.MANAGE_EVENTS).toBe('/manage-events') - expect(APP_ROUTES.MANAGE_EVENT_NEW).toBe('/manage-events/new') expect(APP_ROUTES.MANAGE_EVENT_DETAIL('event/id')).toBe('/manage-events/event%2Fid') expect(APP_ROUTES.MANAGE_EVENT_EDIT('event/id')).toBe('/manage-events/event%2Fid/edit') }) diff --git a/constants/routes.ts b/constants/routes.ts index fc07d6f..e0a3fb4 100644 --- a/constants/routes.ts +++ b/constants/routes.ts @@ -3,7 +3,6 @@ export const APP_ROUTES = { USERS: '/users', USER_DETAIL: (id: string) => `/users/${id}`, MANAGE_EVENTS: '/manage-events', - MANAGE_EVENT_NEW: '/manage-events/new', MANAGE_EVENT_DETAIL: (id: string) => `/manage-events/${encodeURIComponent(id)}`, MANAGE_EVENT_DISCOUNTS: (id: string) => `/manage-events/${encodeURIComponent(id)}?tab=discounts`, MANAGE_EVENT_EDIT: (id: string) => `/manage-events/${encodeURIComponent(id)}/edit`, diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index a3611fd..7805fc8 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -1,8 +1,7 @@ 'use client' -import { hashKey } from '@tanstack/react-query' -import { jwtDecode } from 'jwt-decode' import { createContext, type ReactNode, useCallback, useEffect, useState } from 'react' +import { jwtDecode } from 'jwt-decode' import Cookies from 'js-cookie' import type { @@ -21,13 +20,12 @@ import { parseRefreshExpiry, REFRESH_COOKIE_DAYS } from '@/lib/refreshTokenExpir import { convertPersianToEnglish } from '@/helpers' import { texts } from '@/texts' import { REQUEST_OTP, VERIFY_OTP } from '@/services/auth' -import { COMPLETE_PROFILE, type UserProfile } from '@/services/users' +import { COMPLETE_PROFILE } from '@/services/users' import { unsubscribeCurrentDevicePush } from '@/services/push' import axiosInstance, { refreshAccessTokenProactively } from '@/config/axios' import useLoading from '@/hooks/useLoading' import { clearAsyncResourceCache } from '@/hooks/useAsyncResource' import { clearPersistedQueryCache, getQueryClient } from '@/lib/queryClient' -import { consumerKeys } from '@/queries/consumerKeys' import { setAccountSuspended } from '@/lib/accountSuspension' export const AuthContext = createContext(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 ( { @@ -164,62 +157,3 @@ test('edits discoverability and publishes a draft event', async ({ page }) => { await expect(page.getByText('منتشر‌شده', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: 'انتشار فوری' })).toHaveCount(0) }) - -test('creates a complete event draft through the multi-step wizard', async ({ page }) => { - let createPayload: Record | 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 - 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()) -}) diff --git a/features/events/create/AdminEventCreate.tsx b/features/events/create/AdminEventCreate.tsx deleted file mode 100644 index c4b77f5..0000000 --- a/features/events/create/AdminEventCreate.tsx +++ /dev/null @@ -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: () => , -}) - -const AdminEventCreate = () => { - return ( -
- -
- }> - - -
-
- ) -} - -export default AdminEventCreate diff --git a/features/events/detail/AdminEventDetail.tsx b/features/events/detail/AdminEventDetail.tsx index ccb3848..0300ead 100644 --- a/features/events/detail/AdminEventDetail.tsx +++ b/features/events/detail/AdminEventDetail.tsx @@ -438,11 +438,7 @@ const AdminEventDetail = () => { key="guest-lists" title="مهمانان ویژه" > - + diff --git a/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx b/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx index 8259350..1806b68 100644 --- a/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx +++ b/features/events/detail/admin-event-detail/AdminEventLifecycleActions.tsx @@ -36,13 +36,6 @@ const AdminEventLifecycleActions = ({ ویرایش ) : null} - {event.status === 'draft' && !event.adminApprovedAt ? (
- +
) diff --git a/features/events/edit/EventEditForm.test.tsx b/features/events/edit/EventEditForm.test.tsx index 3b79c08..00824fa 100644 --- a/features/events/edit/EventEditForm.test.tsx +++ b/features/events/edit/EventEditForm.test.tsx @@ -11,7 +11,6 @@ const fetchEventCategories = vi.fn() const fetchAllCities = vi.fn() const fetchEventMedia = vi.fn() const fetchEventFaqs = vi.fn() -const updateEvent = vi.fn() const updateEventAsAdmin = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })) @@ -19,11 +18,9 @@ vi.mock('next/dynamic', () => ({ default: () => () => null, })) vi.mock('@/lib/toast', () => ({ addToast: (...args: unknown[]) => addToast(...args) })) -vi.mock('@/hooks/useAuth', () => ({ default: () => ({ user: { userId: 'user-id' } }) })) vi.mock('@/services/events', () => ({ fetchEventForEdit: (...args: unknown[]) => fetchEventForEdit(...args), fetchEventCategories: (...args: unknown[]) => fetchEventCategories(...args), - updateEvent: (...args: unknown[]) => updateEvent(...args), updateEventAsAdmin: (...args: unknown[]) => updateEventAsAdmin(...args), })) vi.mock('@/services/eventDetail', () => ({ @@ -62,17 +59,13 @@ vi.mock('@/components/events/create/MapLocationPicker', () => ({ default: () =>
, })) vi.mock('@/components/forms/UnsavedChangesIndicator', () => ({ default: () => null })) -vi.mock('@/components/forms/ConsumerFormLayout', () => ({ - ConsumerFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => ( +vi.mock('@/components/forms/AdminFormLayout', () => ({ + AdminFormSection: ({ title, children }: { title: string; children: React.ReactNode }) => (

{title}

{children}
), - ConsumerFormActions: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) -vi.mock('@/components/forms/AdminFormLayout', () => ({ - AdminFormSection: ({ children }: { children: React.ReactNode }) =>
{children}
, AdminFormActions: ({ children }: { children: React.ReactNode }) =>
{children}
, })) vi.mock('@/components/formElements/Button', () => ({ @@ -194,7 +187,7 @@ describe('EventEditForm', () => { fetchAllCities.mockResolvedValue([{ id: 2, name: 'تهران', provinceId: 1 }]) fetchEventMedia.mockResolvedValue(requiredMedia) fetchEventFaqs.mockResolvedValue([]) - updateEvent.mockImplementation(async (_id: string, payload: Record) => ({ + updateEventAsAdmin.mockImplementation(async (_id: string, payload: Record) => ({ ...baseEvent, ...payload, title: typeof payload.title === 'string' ? payload.title : baseEvent.title, @@ -260,9 +253,9 @@ describe('EventEditForm', () => { fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' })) await waitFor(() => { - expect(updateEvent).toHaveBeenCalledOnce() + expect(updateEventAsAdmin).toHaveBeenCalledOnce() }) - const payload = updateEvent.mock.calls[0][1] as Record + const payload = updateEventAsAdmin.mock.calls[0][1] as Record expect(payload.title).toBe('عنوان جدید') expect(payload).toHaveProperty('slug') @@ -276,7 +269,6 @@ describe('EventEditForm', () => { expect(payload).not.toHaveProperty('cancellationFeePercentMoreThan24Hours') expect(payload).not.toHaveProperty('genderRestriction') expect(payload).not.toHaveProperty('ageRestriction') - expect(updateEventAsAdmin).not.toHaveBeenCalled() expect(push).toHaveBeenCalledWith('/my-events') }) @@ -304,6 +296,6 @@ describe('EventEditForm', () => { }) ) }) - expect(updateEvent).not.toHaveBeenCalled() + expect(updateEventAsAdmin).not.toHaveBeenCalled() }) }) diff --git a/features/events/edit/EventEditForm.tsx b/features/events/edit/EventEditForm.tsx index 4d5c0fb..d2b5b19 100644 --- a/features/events/edit/EventEditForm.tsx +++ b/features/events/edit/EventEditForm.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { useRouter } from 'next/navigation' import dynamic from 'next/dynamic' -import clsx from 'clsx' import { texts, format } from '@/texts' import type { CreateEventPayload, CreatedEvent } from '@/services/events' @@ -20,7 +19,6 @@ import { combineDateAndMinutes, hasRequiredPosters } from '@/components/events/c import { DetailSkeleton } from '@/components/feedback/LoadingState' import InlineNotice from '@/components/feedback/InlineNotice' import { AdminFormActions, AdminFormSection } from '@/components/forms/AdminFormLayout' -import { ConsumerFormActions, ConsumerFormSection } from '@/components/forms/ConsumerFormLayout' import UnsavedChangesIndicator from '@/components/forms/UnsavedChangesIndicator' import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints' import { isEventSlugLocked, isFieldLocked, resolveEventEditPolicy } from '@/features/events/eventEditPolicy' @@ -28,15 +26,12 @@ import { syncEventFaqs, syncEventMedia } from '@/features/events/edit/syncEventE import { fetchEventCategories, fetchEventForEdit, - updateEvent, updateEventAsAdmin, type EventCategory, - type EventEditAccessMode, } from '@/services/events' import { fetchEventFaqs, fetchEventMedia, type EventFaq, type EventMedia } from '@/services/eventDetail' import { fetchAllCities, type City } from '@/services/geography' import { extractServerErrorDetail } from '@/services/errorHandler' -import useAuth from '@/hooks/useAuth' import { addToast } from '@/lib/toast' import { showFormValidationToast } from '@/lib/formValidationToast' import { AGE_RESTRICTION_OPTIONS, GENDER_RESTRICTION_OPTIONS } from '@/lib/eventAudience' @@ -80,10 +75,6 @@ const ADDRESS_VISIBILITY_OPTIONS = [ interface EventEditFormProps { eventId: string - /** Selects host vs admin mutation APIs; load uses a single for-edit endpoint. */ - accessMode?: EventEditAccessMode - /** Visual treatment is independent from API/permission mode. */ - layout?: 'admin' | 'consumer' cancelHref: string successHref: string } @@ -135,13 +126,10 @@ function eventToFormValues(event: CreatedEvent): EventEditFormValues { } } -const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', cancelHref, successHref }: EventEditFormProps) => { +const EventEditForm = ({ eventId, cancelHref, successHref }: EventEditFormProps) => { const router = useRouter() - const hasAdminAccess = accessMode === 'admin' - const adminLayout = layout === 'admin' - const FormActions = adminLayout ? AdminFormActions : ConsumerFormActions - const FormSection = adminLayout ? AdminFormSection : ConsumerFormSection - const { user } = useAuth() + const FormActions = AdminFormActions + const FormSection = AdminFormSection const [event, setEvent] = useState(null) const [categories, setCategories] = useState([]) const [cities, setCities] = useState([]) @@ -161,9 +149,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can const startDate = form.watch('startDate') const startTime = form.watch('startTime') const endDate = form.watch('endDate') - const cityId = form.watch('cityId') const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(startDate, startTime, endDate) - const hostCityDisplayName = useMemo(() => cities.find((city) => String(city.id) === cityId)?.name ?? texts.status.empty, [cities, cityId]) const policy = useMemo( () => (event ? resolveEventEditPolicy(event.status, event.bookedCount, event.reservedCapacity ?? 0) : null), @@ -338,8 +324,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can } setIsSaving(true) - const save = hasAdminAccess ? updateEventAsAdmin : updateEvent - const updated = await save(eventId, payload) + const updated = await updateEventAsAdmin(eventId, payload) await syncEventMedia(eventId, initialMedia, media) await syncEventFaqs(eventId, initialFaqs, faqs) @@ -365,10 +350,6 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can return

{loadError ?? texts.events.eventNotFound}

} - if (!hasAdminAccess && user?.userId && event.organizerId !== user.userId) { - return

{texts.events.editOwnerOnly}

- } - if (!policy.canEdit) { return (
@@ -389,7 +370,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can return (
void handleSubmit(e)} > {policy.hasActiveBookings ? ( @@ -400,7 +381,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can description={texts.events.basicInfoCardDescription} title={texts.events.basicInfoCardTitle} > - + - {layout === 'consumer' ? null :

{texts.common.description}

} +

{texts.common.description}

{ form.setValue('description', value, { shouldDirty: true }) }} @@ -460,7 +440,7 @@ const EventEditForm = ({ eventId, accessMode = 'owner', layout = 'consumer', can description={texts.events.wizardStep2Hint} title={texts.events.wizardStep2Label} > - + - {adminLayout ? ( - - ) : ( - -

- {texts.events.hostCityLabel}: - {hostCityDisplayName} -

-
- )} + - +
} > - ) : statusPresentation ? ( -
- -
- ) : null +
+ +
} hostReplyBody={review.hostReplyBody} rating={review.rating} - tone={isAdmin ? 'organizer' : 'consumer'} + tone="organizer" /> ) })} @@ -223,48 +129,6 @@ const EventReviewsPanel = ({ eventId, endsAt, mode = 'host' }: EventReviewsPanel
) : null} - - {!isAdmin ? ( - void clearReply()} - > - {texts.reviews.deleteReply} - - ) : 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) - }} - > - { - setReplyBody(coerceToString(next)) - }} - /> - - ) : null}
) } diff --git a/helpers/index.ts b/helpers/index.ts index 3e22b96..d0b8f4c 100644 --- a/helpers/index.ts +++ b/helpers/index.ts @@ -428,7 +428,7 @@ const SERVICE_ERROR_MESSAGES: Record = { * already narrow to — `(err as { response?: { data?: unknown } })?.response?.data` — so it * is a drop-in replacement for the hand-copied * `extractServerErrorDetail((err as {...})?.response?.data)` block duplicated in - * `EventEditForm.tsx`, `AdminEventDetail.tsx`, `EventCreateWizard.tsx`, and + * `EventEditForm.tsx`, `AdminEventDetail.tsx`, and * `CreateSettlementModal.tsx`. Those call sites currently split the toast into a * context-specific `title` plus a `description` (server detail, falling back to custom copy * per call site); `getServiceErrorMessage`/`showServiceErrorToast` instead produce one diff --git a/queries/admin/adminKeys.ts b/queries/admin/adminKeys.ts index 9316f41..84e6747 100644 --- a/queries/admin/adminKeys.ts +++ b/queries/admin/adminKeys.ts @@ -1,6 +1,6 @@ /** * Query-key factory for admin dashboard data. - * Mirrors queries/consumerKeys.ts -- see frontend/docs/admin-caching.md. + * See docs/admin-caching.md. * * `list()` covers PaginatedList's generic {url + page/pageSize/sort/filters} * shape. `listByUrl()` is the invalidation prefix: passing it to diff --git a/queries/admin/useAdminCitiesQuery.ts b/queries/admin/useAdminCitiesQuery.ts new file mode 100644 index 0000000..74de754 --- /dev/null +++ b/queries/admin/useAdminCitiesQuery.ts @@ -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, + }) diff --git a/queries/consumer/useDiscoveryCitiesQuery.ts b/queries/consumer/useDiscoveryCitiesQuery.ts deleted file mode 100644 index 32fb729..0000000 --- a/queries/consumer/useDiscoveryCitiesQuery.ts +++ /dev/null @@ -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, - }) diff --git a/queries/consumer/usePublicCategoriesQuery.ts b/queries/consumer/usePublicCategoriesQuery.ts deleted file mode 100644 index ac31e55..0000000 --- a/queries/consumer/usePublicCategoriesQuery.ts +++ /dev/null @@ -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, - }) diff --git a/queries/consumerKeys.ts b/queries/consumerKeys.ts deleted file mode 100644 index c36c7db..0000000 --- a/queries/consumerKeys.ts +++ /dev/null @@ -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, -} diff --git a/services/adminWallet.ts b/services/adminWallet.ts index f78440c..6811c52 100644 --- a/services/adminWallet.ts +++ b/services/adminWallet.ts @@ -1,4 +1,4 @@ -import type { WalletTransaction } from '@/services/wallet' +import type { WalletTransactionResponseDto } from '@/api/generated/models' import axiosInstance from '@/config/axios' import { API_ROUTES } from '@/services/config' import { @@ -12,6 +12,8 @@ import { } from '@/services/errorHandler' import { unwrapApiData, type ApiResponse } from '@/services/apiResponse' +export type WalletTransaction = WalletTransactionResponseDto + export interface AdminCreditWalletPayload { /** Amount in Toman (integer). */ amount: number diff --git a/services/bookings.test.ts b/services/bookings.test.ts deleted file mode 100644 index 5b48480..0000000 --- a/services/bookings.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/services/bookings.ts b/services/bookings.ts deleted file mode 100644 index e7ada1c..0000000 --- a/services/bookings.ts +++ /dev/null @@ -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> => { - 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> => { - 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> => { - 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> => { - 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> => { - 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> => { - 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> => { - 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> => { - 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> => { - 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> => { - 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): number => - booking.payableAmount ?? booking.eventPrice diff --git a/services/config.ts b/services/config.ts index 856346e..b2e1c67 100644 --- a/services/config.ts +++ b/services/config.ts @@ -20,15 +20,8 @@ export const API_ROUTES = { ADMIN_REVISION_PENDING: (id: string) => `admin/events/${id}/revisions/pending`, ADMIN_REVISION_APPROVE: (id: string, revisionId: string) => `admin/events/${id}/revisions/${revisionId}/approve`, ADMIN_REVISION_REJECT: (id: string, revisionId: string) => `admin/events/${id}/revisions/${revisionId}/reject`, - DETAIL: (id: string) => `events/${id}`, FOR_EDIT: (id: string) => `events/${id}/for-edit`, - DELETE: (id: string) => `events/${id}`, - OWNER_INSIGHTS: (id: string) => `events/${id}/owner-insights`, - RESERVED_CAPACITY: (id: string) => `events/${id}/reserved-capacity`, ADMIN_RESERVED_CAPACITY: (id: string) => `admin/events/${id}/reserved-capacity`, - REVISION_SUBMIT: (id: string) => `events/${id}/revisions`, - REVISION_PENDING: (id: string) => `events/${id}/revisions/pending`, - REVISION_WITHDRAW: (id: string) => `events/${id}/revisions/pending`, }, EVENT_CATEGORIES: { ADMIN_FLAT: 'admin/event-categories/flat' }, GEOGRAPHY: { ALL_CITIES: 'geography/cities', PROVINCES: 'geography/provinces' }, @@ -39,8 +32,6 @@ export const API_ROUTES = { ADMIN_REJECT: (id: string) => `admin/identity/verifications/${id}/reject`, ADMIN_JIBIT_INQUIRY: (id: string) => `admin/identity/verifications/${id}/jibit-inquiry`, REJECTION_REASONS: 'identity/rejection-reasons', - REQUEST_OTP: 'identity/verifications/request-otp', - CONFIRM_OTP: 'identity/verifications/confirm-otp', }, PAYMENTS: { ADMIN_LIST: 'admin/payments' }, WALLET_DEPOSITS: { ADMIN_LIST: 'admin/wallet-deposits' }, @@ -80,13 +71,9 @@ export const API_ROUTES = { ADMIN_BLOCK: (id: string) => `admin/user-reports/${id}/block`, }, CONTACT_MESSAGES: { - CREATE: 'contact-messages', ADMIN_LIST: 'admin/contact-messages', ADMIN_READ: (id: string) => `admin/contact-messages/${id}/read`, }, - TRAFFIC_VISITS: { - CREATE: 'traffic-visits', - }, SUPPORT_TICKETS: { ADMIN_LIST: 'admin/support-tickets', ADMIN_DETAIL: (id: string) => `admin/support-tickets/${id}`, diff --git a/services/discountCodes.ts b/services/discountCodes.ts index 0bc2480..e6f2089 100644 --- a/services/discountCodes.ts +++ b/services/discountCodes.ts @@ -2,12 +2,10 @@ import type { BulkCreateDiscountCodesResponseDto, CreateDiscountCodesDto, DiscountCodeResponseDto, - DiscountPreviewResponseDto, DiscountRedemptionResponseDto, DiscountReportSummaryDto, } from '@/api/generated/models' import { getDiscountCodes } from '@/api/generated/discount-codes/discount-codes' -import { getFinancial } from '@/api/generated/financial/financial' import axiosInstance from '@/config/axios' import { parseRemittanceList, unwrapApiPayload } from '@/helpers/listResponse' import { texts } from '@/texts' @@ -25,7 +23,6 @@ export type DiscountCode = DiscountCodeResponseDto export type DiscountRedemption = DiscountRedemptionResponseDto export type DiscountReport = DiscountReportSummaryDto export type DiscountBulkResult = BulkCreateDiscountCodesResponseDto -export type DiscountPreview = DiscountPreviewResponseDto export type DiscountType = CreateDiscountCodesDto['type'] export type DiscountBearer = NonNullable @@ -59,7 +56,6 @@ export interface BulkCreateDiscountInput { } const discountApi = getDiscountCodes() -const financialApi = getFinancial() const wrap = async (run: () => Promise, options?: ServiceCallOptions): Promise> => { try { @@ -173,13 +169,3 @@ export const DELETE_DISCOUNT_CODE = (codeId: string, options?: ServiceCallOption wrap(async () => { await discountApi.discountCodesControllerRemove(codeId) }, options) - -export const PREVIEW_DISCOUNT = (bookingId: string, code: string, options?: ServiceCallOptions): Promise> => - wrap(async () => { - const res = await financialApi.paymentsControllerPreviewDiscount(bookingId, { code: code.trim() }) - const data = unwrapApiPayload(res.data) - - if (!data?.code) throw createServiceError(texts.events.discountCodeInvalid) - - return data - }, options) diff --git a/services/discovery.ts b/services/discovery.ts deleted file mode 100644 index 0fcc7d6..0000000 --- a/services/discovery.ts +++ /dev/null @@ -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 { - const map = new Map() - - for (const group of feed?.categoryPreviews ?? []) { - map.set(group.categoryId, group.items) - } - - return map -} - -export async function fetchDiscoveryCities(): Promise { - const response = await eventsApi.discoveryBootstrapControllerListCities() - const payload = unwrapApiPayload(response.data) - - return payload ?? [] -} - -export async function fetchDiscoveryCategories(): Promise { - const response = await eventsApi.discoveryBootstrapControllerListCategories() - const payload = unwrapApiPayload(response.data) - - return payload ?? [] -} - -export async function fetchHomeFeed(query: HomeFeedQuery = {}, options?: ServiceCallOptions): Promise> { - try { - const response = await eventsApi.discoveryBootstrapControllerGetHomeFeed({ - cityId: query.cityId, - }) - const payload = unwrapApiPayload(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 { - const response = await axiosInstance.get('discovery/bootstrap') - const payload = unwrapApiPayload(response.data) - - return payload ?? { categories: [], cities: [] } -} - -/** Full category list via event-categories module (admin-adjacent surfaces). */ -export async function fetchEventCategoriesList(options?: ServiceCallOptions): Promise> { - 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> { - try { - const params: { - page: number - pageSize: number - sort?: string - filters: Record - } = { - 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) - } -} diff --git a/services/eventCategories.ts b/services/eventCategories.ts index 1b8b6b0..6fc4274 100644 --- a/services/eventCategories.ts +++ b/services/eventCategories.ts @@ -1,4 +1,4 @@ -import type { CreateEventCategoryDto, EventCategoryResponseDto } from '@/api/generated/models' +import type { CreateEventCategoryDto, EventCategoryResponseDto, EventCategorySummaryResponseDto } from '@/api/generated/models' import { createServiceError, errorResult, @@ -10,26 +10,20 @@ import { } from '@/services/errorHandler' import { unwrapApiData } from '@/services/apiResponse' import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories' -import { getEventCategories } from '@/api/generated/event-categories/event-categories' import { texts } from '@/texts' export type EventCategory = EventCategoryResponseDto +export type EventCategorySummary = EventCategorySummaryResponseDto export type EventCategoryPayload = CreateEventCategoryDto -const eventCategoriesApi = getEventCategories() const adminEventCategoriesApi = getAdminEventCategories() -/** - * Public, unpaginated flat list of *active* categories — `GET /event-categories` - * (see backend/src/modules/event-categories/event-categories.controller.ts). - * Used by the consumer app's categories tab; unlike GET_CATEGORY_CHILDREN - * below, this returns every active category in one call (parentId included - * per row), so the caller builds the tree client-side instead of fetching - * level-by-level. - */ -export const LIST_PUBLIC_CATEGORIES = async (options?: ServiceCallOptions): Promise> => { +/** Flat admin category list for filters/selects (id + name). */ +export const LIST_ADMIN_CATEGORIES_FLAT = async ( + options?: ServiceCallOptions +): Promise> => { try { - const res = await eventCategoriesApi.eventCategoriesControllerList() + const res = await adminEventCategoriesApi.adminEventCategoriesControllerListFlat() const payload = res.data if (!payload.success) { @@ -52,10 +46,7 @@ export const LIST_PUBLIC_CATEGORIES = async (options?: ServiceCallOptions): Prom /** * One "level" of the category tree: every direct child of `parentId` - * (or every root category, when `parentId` is null). Page size is fixed - * at the backend's max (100) — there's no pagination UI within a tree - * level; if an organizer ever needs more than 100 siblings under one - * parent, that's a real product problem, not something to paginate away. + * (or every root category, when `parentId` is null). */ export const GET_CATEGORY_CHILDREN = async ( parentId: number | null, diff --git a/services/eventDetail.ts b/services/eventDetail.ts index 033990e..af3ab71 100644 --- a/services/eventDetail.ts +++ b/services/eventDetail.ts @@ -9,8 +9,8 @@ export type EventFaq = EventFaqResponseDto const eventExtrasApi = getEventExtras() /** - * `GET /events/:eventId/media` — public, unpaginated, ordered by `sortOrder` asc. - * See docs/workflows/fa/event-discovery.md, stage 3. + * Shared public list endpoints — backend allows admin reads for event detail/edit. + * No dedicated admin media/FAQ list routes exist in OpenAPI. */ export async function fetchEventMedia(eventId: string): Promise { const response = await eventExtrasApi.eventMediaControllerList(eventId) @@ -19,9 +19,6 @@ export async function fetchEventMedia(eventId: string): Promise { return Array.isArray(payload) ? (payload as EventMedia[]) : [] } -/** - * `GET /events/:eventId/faqs` — public, unpaginated, ordered by `sortOrder` asc. - */ export async function fetchEventFaqs(eventId: string): Promise { const response = await eventExtrasApi.eventFaqsControllerList(eventId) const payload = unwrapApiPayload(response.data) diff --git a/services/eventGuestListAccess.ts b/services/eventGuestListAccess.ts index eaaa26c..3814259 100644 --- a/services/eventGuestListAccess.ts +++ b/services/eventGuestListAccess.ts @@ -1,35 +1,21 @@ import type { GuestListLinkResponseDto, InvitedGuestResponseDto } from '@/api/generated/models' import { getAdminEventExtras } from '@/api/generated/admin-event-extras/admin-event-extras' -import { getEventExtras } from '@/api/generated/event-extras/event-extras' import { unwrapApiPayload } from '@/helpers/listResponse' -export type EventGuestListAccessMode = 'admin' | 'owner' export type EventGuestListLink = GuestListLinkResponseDto export type EventInvitedGuest = InvitedGuestResponseDto const adminEventExtrasApi = getAdminEventExtras() -const eventExtrasApi = getEventExtras() - -export async function fetchEventGuestListLinks(eventId: string, accessMode: EventGuestListAccessMode): Promise { - const response = - accessMode === 'admin' - ? await adminEventExtrasApi.adminEventGuestListLinksControllerList(eventId) - : await eventExtrasApi.eventGuestListLinksControllerList(eventId) +export async function fetchEventGuestListLinks(eventId: string): Promise { + const response = await adminEventExtrasApi.adminEventGuestListLinksControllerList(eventId) const payload = unwrapApiPayload(response.data) return Array.isArray(payload) ? payload : [] } -export async function fetchEventInvitedGuests(eventId: string, accessMode: EventGuestListAccessMode): Promise { - if (accessMode === 'admin') { - const response = await adminEventExtrasApi.adminEventInvitedGuestsControllerGet(eventId) - const payload = unwrapApiPayload(response.data) - - return Array.isArray(payload) ? payload : [] - } - - const response = await eventExtrasApi.eventGuestListLinksControllerGetInvitedGuests(eventId) +export async function fetchEventInvitedGuests(eventId: string): Promise { + const response = await adminEventExtrasApi.adminEventInvitedGuestsControllerGet(eventId) const payload = unwrapApiPayload(response.data) return Array.isArray(payload) ? payload : [] diff --git a/services/eventManagement.ts b/services/eventManagement.ts index e77cc8d..3aae96b 100644 --- a/services/eventManagement.ts +++ b/services/eventManagement.ts @@ -1,25 +1,7 @@ -import type { - EventAttendeeFilter, - EventAttendeeResponseDto, - MyEventsControllerAttendees200, - OwnerEventInsightsResponseDto, -} from '@/api/generated/models' +import type { OwnerEventInsightsResponseDto } from '@/api/generated/models' import { getAdminBookings } from '@/api/generated/admin-bookings/admin-bookings' import { getAdminEvents } from '@/api/generated/admin-events/admin-events' -import { getBookings } from '@/api/generated/bookings/bookings' -import { getMyEvents } from '@/api/generated/my-events/my-events' import { unwrapApiPayload } from '@/helpers/listResponse' -import axiosInstance from '@/config/axios' -import type { CreatedEvent } from '@/services/events' -import type { EventFaq, EventMedia } from '@/services/eventDetail' - -export type EventAttendee = Omit & { - firstName: string | null - lastName: string | null - confirmedAt: string | null - checkedInAt: string | null - noShowAt: string | null -} /** Extended until OpenAPI client is regenerated with traffic insights. */ export type EventManagementInsights = OwnerEventInsightsResponseDto & { @@ -28,41 +10,10 @@ export type EventManagementInsights = OwnerEventInsightsResponseDto & { telegram: { uniqueVisits: number; confirmedBookings: number } } } -export type AttendanceFilter = EventAttendeeFilter -export interface EventManagementBootstrap { - event: CreatedEvent - insights: EventManagementInsights - categoryName: string - cityName: string - provinceName: string - media: EventMedia[] - faqs: EventFaq[] -} - -export interface EventAttendeePage { - items: EventAttendee[] - totalItemsCount: number - totalPages: number -} - -const myEventsApi = getMyEvents() -const bookingsApi = getBookings() const adminEventsApi = getAdminEvents() const adminBookingsApi = getAdminBookings() -export async function fetchEventManagementInsights(eventId: string): Promise { - const response = await myEventsApi.myEventsControllerOwnerInsights(eventId) - - return unwrapApiPayload(response.data) -} - -export async function fetchEventManagementBootstrap(eventId: string): Promise { - const response = await axiosInstance.get(`events/${eventId}/management-bootstrap`) - - return unwrapApiPayload(response.data) -} - /** Admin can view insights for any organizer's event (no ownership check). */ export async function fetchEventInsightsAsAdmin(eventId: string): Promise { const response = await adminEventsApi.adminEventsControllerInsights(eventId) @@ -70,35 +21,6 @@ export async function fetchEventInsightsAsAdmin(eventId: string): Promise(response.data) } -export async function fetchEventAttendees( - eventId: string, - options: { - search?: string - attendance?: AttendanceFilter - page?: number - pageSize?: number - } = {} -): Promise { - 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(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 { - await bookingsApi.bookingsControllerCheckIn(bookingId) -} - /** Admin can check in a guest for any organizer's event (no ownership check). */ export async function checkInBookingAsAdmin(bookingId: string): Promise { await adminBookingsApi.adminBookingsControllerCheckIn(bookingId) diff --git a/services/events.ts b/services/events.ts index a861578..986c01e 100644 --- a/services/events.ts +++ b/services/events.ts @@ -4,112 +4,45 @@ import type { CreateEventMediaDto, EventCategoryResponseDto, EventResponseDto, - OrganizerGuestListResponseDto, } from '@/api/generated/models' +import { getAdminEventCategories } from '@/api/generated/admin-event-categories/admin-event-categories' import { getAdminEvents } from '@/api/generated/admin-events/admin-events' -import { getEventCategories } from '@/api/generated/event-categories/event-categories' import { getEventExtras } from '@/api/generated/event-extras/event-extras' -import { getMyEvents } from '@/api/generated/my-events/my-events' import { unwrapApiPayload } from '@/helpers/listResponse' import axiosInstance from '@/config/axios' import { API_ROUTES } from '@/services/config' -import { texts } from '@/texts' -import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics' export type EventCategory = EventCategoryResponseDto export type CreateEventPayload = CreateEventDto export type CreatedEvent = EventResponseDto export type CreateEventMediaPayload = CreateEventMediaDto export type CreateEventFaqPayload = CreateEventFaqDto -export type OrganizerGuestList = OrganizerGuestListResponseDto -export interface PreviousAttendee { - userId: string - firstName: string | null - lastName: string | null -} -interface MyHostedEventSummary { - id: string - title: string - slug: string - status: string - startsAt: string - bookedCount: number - reservedCapacity?: number - capacity: number - isFree: boolean - price: number - createdAt: string - adminApprovedAt?: string | null - rejectionReason?: string | null -} - -/** The endpoint returns the full event today while this intersection keeps older cached summaries valid. */ -export type MyHostedEvent = MyHostedEventSummary & Partial> - -const myEventsApi = getMyEvents() const adminEventsApi = getAdminEvents() -const eventCategoriesApi = getEventCategories() +const adminEventCategoriesApi = getAdminEventCategories() const eventExtrasApi = getEventExtras() +/** Full category tree for admin edit forms — admin list, not public discovery. */ export async function fetchEventCategories(): Promise { - const response = await eventCategoriesApi.eventCategoriesControllerList() - const payload = unwrapApiPayload(response.data) + const items: EventCategory[] = [] + let page = 1 + let totalPages = 1 - return Array.isArray(payload) ? (payload as EventCategory[]) : [] -} + while (page <= totalPages) { + const response = await adminEventCategoriesApi.adminEventCategoriesControllerList({ + page, + pageSize: 100, + sort: 'sortOrder', + }) + const payload = unwrapApiPayload<{ items?: EventCategory[]; response?: { totalPages?: number } }>(response.data) + const batch = Array.isArray(payload.items) ? payload.items : Array.isArray(payload) ? (payload as EventCategory[]) : [] -export async function createEvent(payload: CreateEventPayload): Promise { - const response = await myEventsApi.myEventsControllerCreate(payload) - const data = unwrapApiPayload(response.data) - - if (!data?.id) { - throw new Error(texts.common.invalidServerResponse) + items.push(...batch) + totalPages = Number(payload.response?.totalPages ?? 1) + page += 1 } - trackAnalyticsEventOnce(ANALYTICS_EVENTS.EVENT_CREATED, data.id, { - event_id: data.id, - event_slug: data.slug, - category_id: payload.categoryId, - is_free: payload.isFree, - }) - - return data -} - -// Self-service endpoints (events/:id, events/:id/publish) — ownership- -// checked server-side against the logged-in user. Only works if the -// currently logged-in user is the event's own organizer_id. For an admin -// acting on any organizer's event, use the *AsAdmin variants below instead. -export async function publishEvent(eventId: string): Promise { - const response = await myEventsApi.myEventsControllerPublish(eventId) - const event = unwrapApiPayload(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 { - const response = await myEventsApi.myEventsControllerCancel(eventId) - - return unwrapApiPayload(response.data) -} - -export async function completeEvent(eventId: string): Promise { - const response = await myEventsApi.myEventsControllerComplete(eventId) - - return unwrapApiPayload(response.data) -} - -/** Soft-delete when booked_count = 0. */ -export async function deleteEvent(eventId: string): Promise { - const response = await axiosInstance.delete(API_ROUTES.EVENTS.DELETE(eventId)) - - return unwrapApiPayload(response.data) + return items } /** Admin approval is one half of the two-key publication workflow. */ @@ -150,21 +83,8 @@ export async function deleteEventAsAdmin(eventId: string): Promise return unwrapApiPayload(response.data) } -export type EventEditAccessMode = 'admin' | 'owner' - -export async function updateEvent(eventId: string, payload: Partial): Promise { - const response = await myEventsApi.myEventsControllerUpdate(eventId, payload) - - return unwrapApiPayload(response.data) -} - -export async function updateEventReservedCapacity( - eventId: string, - reservedCapacity: number, - accessMode: EventEditAccessMode = 'owner' -): Promise { - const path = accessMode === 'admin' ? API_ROUTES.EVENTS.ADMIN_RESERVED_CAPACITY(eventId) : API_ROUTES.EVENTS.RESERVED_CAPACITY(eventId) - const response = await axiosInstance.patch(path, { reservedCapacity }) +export async function updateEventReservedCapacity(eventId: string, reservedCapacity: number): Promise { + const response = await axiosInstance.patch(API_ROUTES.EVENTS.ADMIN_RESERVED_CAPACITY(eventId), { reservedCapacity }) return unwrapApiPayload(response.data) } @@ -184,8 +104,8 @@ export async function updateEventCommissionAsAdmin(eventId: string, commissionPe } /** - * Full event for edit/clone forms. Backend authorizes owner OR admin; - * callers do not pass access mode. + * Full event for edit forms. Backend authorizes admin (and owner); + * admin panel uses this for the edit screen. */ export async function fetchEventForEdit(eventId: string): Promise { const response = await axiosInstance.get(API_ROUTES.EVENTS.FOR_EDIT(eventId)) @@ -200,6 +120,10 @@ export async function fetchAdminEventDetail(eventId: string): Promise(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 { await eventExtrasApi.eventMediaControllerCreate(eventId, payload) } @@ -262,29 +186,6 @@ export interface EventRevision { decidedAt: string | null } -export async function submitEventRevision(eventId: string, payload: EventRevisionPayload): Promise { - const response = await axiosInstance.post(API_ROUTES.EVENTS.REVISION_SUBMIT(eventId), payload) - - return unwrapApiPayload(response.data) -} - -export async function fetchPendingEventRevision(eventId: string): Promise { - try { - const response = await axiosInstance.get(API_ROUTES.EVENTS.REVISION_PENDING(eventId)) - - return unwrapApiPayload(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 { - await axiosInstance.delete(API_ROUTES.EVENTS.REVISION_WITHDRAW(eventId)) -} - export async function fetchAdminPendingEventRevision(eventId: string): Promise { try { const response = await axiosInstance.get(API_ROUTES.EVENTS.ADMIN_REVISION_PENDING(eventId)) @@ -311,28 +212,3 @@ export async function rejectEventRevisionAsAdmin(eventId: string, revisionId: st return unwrapApiPayload(response.data) } - -export async function linkGuestListToEvent(eventId: string, listId: string): Promise { - await eventExtrasApi.eventGuestListLinksControllerLink(eventId, { listId }) -} - -export async function fetchOrganizerGuestLists(): Promise { - 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 { - 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 { - // 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 }) -} diff --git a/services/push.ts b/services/push.ts index 7bc83f5..547f7ab 100644 --- a/services/push.ts +++ b/services/push.ts @@ -1,6 +1,4 @@ -import type { PushSubscriptionResponseDto, PushVapidPublicKeyResponseDto } from '@/api/generated/models' import { - createServiceError, errorResult, handleServiceError, type ServiceCallOptions, @@ -8,65 +6,12 @@ import { shouldBubbleErrorToParent, successResult, } from '@/services/errorHandler' -import { unwrapApiData } from '@/services/apiResponse' import { getNotifications } from '@/api/generated/notifications/notifications' import { clearStoredPushSubscriptionId, getStoredPushSubscriptionId, unsubscribeBrowserPushManager } from '@/lib/pwa' -import { texts } from '@/texts' - -export type PushSubscriptionRecord = PushSubscriptionResponseDto -export type VapidPublicKeyResult = PushVapidPublicKeyResponseDto const notificationsApi = getNotifications() -export const GET_VAPID_PUBLIC_KEY = async (options?: ServiceCallOptions): Promise> => { - 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> => { - try { - const res = await notificationsApi.notificationsControllerSubscribePush(data) - - if (!res.data.success) throw createServiceError(res.data.message) - const payload = unwrapApiData(res.data) - - if (!payload?.id) { - throw createServiceError(texts.common.invalidServerResponse) - } - - return successResult(payload) - } catch (error) { - const normalizedError = handleServiceError(error, options) - - if (shouldBubbleErrorToParent(options)) { - throw normalizedError - } - - return errorResult(normalizedError) - } -} - -export const UNSUBSCRIBE_PUSH = async ( +const UNSUBSCRIBE_PUSH = async ( subscriptionId: string, options?: ServiceCallOptions ): Promise> => { @@ -85,32 +30,7 @@ export const UNSUBSCRIBE_PUSH = async ( } } -export const UNSUBSCRIBE_ALL_PUSH = async (options?: ServiceCallOptions): Promise> => { - try { - const res = await notificationsApi.notificationsControllerUnsubscribeAllPush() - - if (!res.data.success) throw createServiceError(res.data.message) - const payload = unwrapApiData(res.data) - - return successResult({ deletedCount: Number(payload?.deletedCount ?? 0) }) - } catch (error) { - const normalizedError = handleServiceError(error, options) - - if (shouldBubbleErrorToParent(options)) { - throw normalizedError - } - - return errorResult(normalizedError) - } -} - -/** - * Removes push delivery for *this* device only: deletes the server-side - * subscription row (if we have its id) and unsubscribes the browser's - * PushManager. Intended for logout — logging out on one device should not - * silence push notifications on the user's other logged-in devices, which - * UNSUBSCRIBE_ALL_PUSH would do. - */ +/** Best-effort cleanup on admin logout — no subscribe flow in this app. */ export const unsubscribeCurrentDevicePush = async (): Promise => { const subscriptionId = getStoredPushSubscriptionId() @@ -119,7 +39,7 @@ export const unsubscribeCurrentDevicePush = async (): Promise => { await UNSUBSCRIBE_PUSH(subscriptionId, { errorMode: 'silent' }) } } finally { - await unsubscribeBrowserPushManager() clearStoredPushSubscriptionId() + await unsubscribeBrowserPushManager().catch(() => undefined) } } diff --git a/services/reviews.ts b/services/reviews.ts index 128c41d..6da605d 100644 --- a/services/reviews.ts +++ b/services/reviews.ts @@ -1,14 +1,5 @@ -import type { - AdminReviewResponseDto, - CreateReviewDto, - HostReplyDto, - OrganizerReviewResponseDto, - ReviewResponseDto, - UpdateReviewDto, -} from '@/api/generated/models' +import type { AdminReviewResponseDto } from '@/api/generated/models' import { getAdminReviews } from '@/api/generated/admin-reviews/admin-reviews' -import { getReviews } from '@/api/generated/reviews/reviews' -import { unwrapApiPayload } from '@/helpers/listResponse' import { parseRemittanceList } from '@/helpers/listResponse' import { errorResult, @@ -19,58 +10,11 @@ import { successResult, } from '@/services/errorHandler' -export type EventReview = ReviewResponseDto export type AdminEventReview = AdminReviewResponseDto -export type OrganizerReview = OrganizerReviewResponseDto -const reviewsApi = getReviews() const adminReviewsApi = getAdminReviews() export const EVENT_REVIEWS_PAGE_SIZE = 10 -export const ORGANIZER_REVIEWS_PAGE_SIZE = 10 - -interface PaginatedReviews { - items: T[] - response?: { totalItemsCount?: number; totalPages?: number; page?: number } -} - -const unwrapItems = (payload: unknown): T[] => { - if (Array.isArray(payload)) return payload as T[] - if (payload && typeof payload === 'object' && Array.isArray((payload as PaginatedReviews).items)) { - return (payload as PaginatedReviews).items - } - - return [] -} - -export const LIST_EVENT_REVIEWS_PAGE = async ( - eventId: string, - page = 1, - pageSize = EVENT_REVIEWS_PAGE_SIZE, - options?: ServiceCallOptions -): Promise> => { - try { - const res = await reviewsApi.reviewsControllerListByEvent(eventId, { - sort: '-createdAt', - page, - pageSize, - }) - const parsed = parseRemittanceList(res.data, 'items', page, pageSize) - - return successResult({ - items: parsed.items, - page: parsed.pagination.page, - totalItemsCount: parsed.pagination.totalItemsCount, - totalPages: parsed.pagination.totalPages ?? 0, - }) - } catch (error) { - const normalizedError = handleServiceError(error, options) - - if (shouldBubbleErrorToParent(options)) throw normalizedError - - return errorResult(normalizedError) - } -} /** Admin list for one event — includes published, hidden, and deleted reviews. */ export const LIST_ADMIN_EVENT_REVIEWS_PAGE = async ( @@ -102,126 +46,3 @@ export const LIST_ADMIN_EVENT_REVIEWS_PAGE = async ( return errorResult(normalizedError) } } - -export const LIST_EVENT_REVIEWS = async (eventId: string, options?: ServiceCallOptions): Promise> => { - 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> => { - try { - const res = await reviewsApi.reviewsControllerListByOrganizer(organizerId, { - sort: '-createdAt', - page: 1, - pageSize: 50, - }) - - return successResult(unwrapItems(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> => { - try { - const res = await reviewsApi.reviewsControllerListByOrganizer(organizerId, { - sort: '-createdAt', - page, - pageSize, - }) - const parsed = parseRemittanceList(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> => { - 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> => { - 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> => { - 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> => { - 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) - } -} diff --git a/services/supportTickets.ts b/services/supportTickets.ts index 046e6bc..106a72e 100644 --- a/services/supportTickets.ts +++ b/services/supportTickets.ts @@ -1,6 +1,6 @@ import axiosInstance from '@/config/axios' import { texts } from '@/texts' -import { parseRemittanceList, unwrapApiDataUnknown } from '@/helpers/listResponse' +import { unwrapApiDataUnknown } from '@/helpers/listResponse' export interface SupportTicketMessage { id: string @@ -31,35 +31,6 @@ export const SUPPORT_ADMIN_STATUS_LABELS: Record = { ...texts.su const unwrapTicket = (raw: unknown): SupportTicket => unwrapApiDataUnknown(raw) as SupportTicket -export async function listMySupportTickets(): Promise { - const response = await axiosInstance.get('support-tickets/me', { params: { pageSize: 100, sort: '-lastMessageAt' } }) - - return parseRemittanceList(response.data).items -} - -export async function getMySupportTicket(id: string): Promise { - 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 { - const response = await axiosInstance.post('support-tickets', payload) - - return unwrapTicket(response.data) -} - -export async function replyToSupportTicket(id: string, message: string): Promise { - const response = await axiosInstance.post(`support-tickets/${id}/replies`, { message }) - - return unwrapTicket(response.data) -} - export async function getAdminSupportTicket(id: string): Promise { const response = await axiosInstance.get(`admin/support-tickets/${id}`) diff --git a/services/users.ts b/services/users.ts index 5a5465a..d3f5011 100644 --- a/services/users.ts +++ b/services/users.ts @@ -1,4 +1,4 @@ -import type { CompleteProfileDto, UpdateUserProfileDto, UserProfileResponseDto } from '@/api/generated/models' +import type { CompleteProfileDto, UserProfileResponseDto } from '@/api/generated/models' import { createServiceError, errorResult, @@ -15,76 +15,10 @@ import { texts } from '@/texts' export type UserProfile = UserProfileResponseDto export type CompleteProfilePayload = CompleteProfileDto -export type UpdateProfilePayload = UpdateUserProfileDto const usersApi = getUsers() -export const GET_ME = async (options?: ServiceCallOptions): Promise> => { - 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> => { - try { - const body: UpdateProfilePayload = {} - - if (data.firstName !== undefined) body.firstName = data.firstName.trim() - if (data.lastName !== undefined) body.lastName = data.lastName.trim() - if (data.gender !== undefined) body.gender = data.gender - if (data.cityId !== undefined) body.cityId = data.cityId - if (data.bio !== undefined) body.bio = data.bio.trim() - if (data.avatarUrl !== undefined) body.avatarUrl = data.avatarUrl - if (data.defaultAddress !== undefined) { - body.defaultAddress = data.defaultAddress === null ? null : data.defaultAddress.trim() - } - - const res = await usersApi.usersControllerUpdateMe(body) - const payload = res.data - - if (!payload.success) { - throw createServiceError(payload.message || texts.profile.updateProfileFailed) - } - - const profile = unwrapApiData(payload) - - if (!profile?.id) { - throw createServiceError(texts.common.invalidServerResponse) - } - - return successResult(profile) - } catch (error) { - const normalizedError = handleServiceError(error, options) - - if (shouldBubbleErrorToParent(options)) { - throw normalizedError - } - - return errorResult(normalizedError) - } -} - +/** Completes first-time admin profile after OTP registration. */ export const COMPLETE_PROFILE = async (data: CompleteProfilePayload, options?: ServiceCallOptions): Promise> => { try { const res = await usersApi.usersControllerCompleteProfile({ diff --git a/services/wallet.ts b/services/wallet.ts deleted file mode 100644 index 4cd53b0..0000000 --- a/services/wallet.ts +++ /dev/null @@ -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> => { - 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> => { - 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> => { - 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> => { - 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(unwrapApiData(payload) ?? {}, 'items') - - return successResult({ items: parsed.items }) - } catch (error) { - const normalizedError = handleServiceError(error, options) - - if (shouldBubbleErrorToParent(options)) { - throw normalizedError - } - - return errorResult(normalizedError) - } -} diff --git a/texts/auth.ts b/texts/auth.ts index 681fdbf..67b5489 100644 --- a/texts/auth.ts +++ b/texts/auth.ts @@ -1,53 +1,26 @@ export const auth = { logoAlt: 'لوگوی قبیله', - heroAlt: 'یک دورهمی هنری و تجربه گروهی در قبیله', preparingLogin: 'در حال آماده‌سازی ورود', - loading: 'در حال بارگذاری...', - otpTitle: 'کد ارسال‌شده را وارد کنید', profileTitle: 'تکمیل اطلاعات پروفایل', - welcomeTitle: 'سلام!', - welcomeDescription: 'به قبیله خوش آمدید. جهت استفاده از وبسایت وارد حساب کاربری خود شوید.', - otpLoginTitle: 'ورود با کد یکبار مصرف', - otpRegisterTitle: 'ایجاد حساب کاربری', - otpLoginDescription: 'کد ورود به شماره {mobile} ارسال شد.', - otpRegisterDescription: 'حساب کاربری با شماره موبایل {mobile} وجود ندارد. برای ساخت حساب جدید، کد تأیید برای این شماره ارسال گردید.', - profileDescription: 'جهت تکمیل حساب خود، اطلاعات خود را وارد کنید.', - termsNoticePrefix: 'ورود شما به معنای پذیرش', - termsNoticeTermsLink: 'شرایط قبیله', - privacyLink: 'قوانین حریم خصوصی', - termsNoticeConnector: 'و', - termsNoticeSuffix: 'است.', - cityPickerTitle: 'شهرتان را انتخاب کنید', - birthDatePickerTitle: 'تاریخ تولدتان را انتخاب کنید', - mobileLabel: 'شماره موبایل', mobilePlaceholder: 'مثال ۰۹۱۲۳۴۵۶۷۸۹', - termsConfirmPrefix: 'تأیید می‌کنم', termsLink: 'قوانین و مقررات قبیله', termsConfirmSuffix: 'را خوانده‌ام و می‌پذیرم.', - confirmAndContinueRegister: 'تأیید و ادامه ثبت‌نام', - resendTimerAria: 'تایمر ارسال مجدد کد', didNotReceiveCode: 'کد را دریافت نکردید؟', resendInSeconds: 'ارسال مجدد کد تا {seconds} ثانیه دیگر', resendCode: 'ارسال مجدد کد', changeMobile: 'تغییر شماره موبایل', - loadingCities: 'در حال دریافت شهرها...', citiesLoadFailed: 'دریافت فهرست شهرها ناموفق بود.', completeRegistration: 'تکمیل ثبت‌نام', - accountSuspended: 'حساب کاربری شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید.', accountDeleted: 'این حساب کاربری حذف شده است و امکان ورود وجود ندارد.', otpSent: 'کد تایید به شماره موبایل شما ارسال شد', otpAlreadySent: 'پیامک قبلاً ارسال شده و می‌توانید از همان کد استفاده کنید', - otpSentToNumberPrefix: 'کد تایید به شماره', - otpSentToNumberSuffix: 'ارسال شد', - otpAlreadySentToNumberPrefix: 'پیامک قبلاً به شماره', - otpAlreadySentToNumberSuffix: 'ارسال شده و همان کد هنوز معتبر است', sendOtpFailed: 'ارسال کد با خطا مواجه شد. دوباره تلاش کنید.', termsRequiredMessage: 'برای ادامه ثبت‌نام، پذیرش قوانین و مقررات الزامی است.', termsRequiredTitle: 'پذیرش قوانین الزامی است', @@ -55,7 +28,6 @@ export const auth = { otpInvalidOrExpired: 'کد وارد شده نامعتبر یا منقضی شده است.', otpVerifyFailed: 'تایید کد با خطا مواجه شد. دوباره تلاش کنید.', profileCompleteFailed: 'تکمیل پروفایل با خطا مواجه شد. دوباره تلاش کنید.', - sendOtpFailedFallback: 'ارسال کد تایید ناموفق بود', otpInvalidFallback: 'کد تایید نامعتبر است', invalidLoginResponse: 'پاسخ ورود نامعتبر است', diff --git a/texts/blog.ts b/texts/blog.ts index ae67b4f..337e0e1 100644 --- a/texts/blog.ts +++ b/texts/blog.ts @@ -1,33 +1,4 @@ export const blog = { - magazineLabel: 'مجله قبیله', - magazineShort: 'مجله', - editorialName: 'تحریریه قبیله', - readingMinutes: '{minutes} دقیقه مطالعه', - publishedPrefix: 'انتشار {date}', - featuredImageAlt: 'تصویر شاخص مقاله {title}', - readArticle: 'مطالعه مقاله ← ', - emptySection: 'مقاله‌ای در این بخش منتشر نشده است.', - viewAllArticles: 'مشاهده همه مقاله‌ها', - relatedArticles: 'مطالب مرتبط', - topicsHeading: 'موضوع‌ها', - cityGuidesHeading: 'راهنمای شهرها', - eventsInCity: 'رویدادهای {cityName}', - relatedEventsCta: 'مشاهده رویدادهای مرتبط', - listing: { - indexTitle: 'راهنمای تجربه‌های بهتر، قبل و بعد از رویداد', - indexDescription: 'مطلب‌های دقیق و قابل‌اجرا برای اینکه رویداد مناسب‌تری پیدا کنید یا تجربه بهتری برای مهمان‌ها بسازید.', - categoryDescription: 'مجموعه راهنماهای کاربردی قبیله درباره {categoryName}.', - cityTitle: 'راهنمای رویدادهای {cityName}', - cityDescription: 'مطالب محلی برای پیدا کردن و تجربه بهتر رویدادهای {cityName}.', - }, - meta: { - indexTitle: 'مجله رویدادها و تجربه‌های گروهی', - indexDescription: 'راهنماهای کاربردی برای انتخاب رویداد، شرکت در ورکشاپ و برگزاری تجربه‌های گروهی بهتر.', - indexOgDescription: 'راهنمای تجربه و برگزاری رویدادهای بهتر', - categoryTitle: '{categoryName}؛ مقالات و راهنماها', - categoryDescription: 'مقالات کاربردی {categoryName} در مجله قبیله.', - cityDescription: 'راهنما و نکات انتخاب و شرکت در رویدادهای {cityName}.', - }, errors: { fetchFailed: 'دریافت مقاله ناموفق بود', createFailed: 'ایجاد مقاله ناموفق بود', diff --git a/texts/bookings.ts b/texts/bookings.ts deleted file mode 100644 index b921310..0000000 --- a/texts/bookings.ts +++ /dev/null @@ -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 diff --git a/texts/chats.ts b/texts/chats.ts index 8846b9c..caa97fd 100644 --- a/texts/chats.ts +++ b/texts/chats.ts @@ -1,74 +1,6 @@ export const chats = { - listTitle: 'گفت‌وگوها', - listDescription: 'پیام‌های مستقیم و گروه‌های رویداد', - filterAriaLabel: 'فیلتر نوع مکالمه', - filterDirect: 'مستقیم', - filterEventGroup: 'گروه رویداد', - unreadAriaLabel: '{count} پیام خوانده‌نشده', - listLoadFailed: 'دریافت گفت‌وگوها ناموفق بود', - listEmpty: 'گفت‌وگویی در این دسته وجود ندارد', - listEmptyTitle: 'هنوز گفت‌وگویی اینجا نیست', - listEmptyDescription: 'وقتی با کسی پیام رد و بدل کنی یا عضو گروه رویداد شوی، گفت‌وگوها اینجا نمایش داده می‌شوند.', - eventGroupTitleFallback: 'چت گروه رویداد', - directTitleFallback: 'مکالمه مستقیم', - conversationFallback: 'مکالمه', - moreOptionsAria: 'گزینه‌های بیشتر', - safetyMenuAria: 'گزینه‌های ایمنی چت', - block: 'مسدود کردن', - unblock: 'رفع مسدودیت', - reportUser: 'گزارش کاربر', - reportMessage: 'گزارش پیام', - threadLoadFailed: 'دریافت گفت‌وگو ناموفق بود', - youBlockedBanner: 'شما این کاربر را مسدود کرده‌اید و امکان ارسال یا دریافت پیام وجود ندارد.', - reportModalTitle: 'گزارش این کاربر', - reportMessageModalTitle: 'گزارش این پیام', - reportSubmit: 'ارسال گزارش', - reportReasonLabel: 'دلیل گزارش', - reportDescriptionLabel: 'توضیحات (اختیاری)', - reportDescriptionPlaceholder: 'در صورت نیاز توضیح بیشتری اضافه کنید...', - reportSuccess: 'گزارش شما ثبت شد و بررسی خواهد شد', - blockConfirm: 'این کاربر مسدود شود؟ پس از مسدودسازی، امکان ارسال و دریافت پیام مستقیم بین شما وجود نخواهد داشت.', - unblockConfirm: 'این کاربر رفع مسدودیت شود؟ پس از آن دوباره می‌توانید پیام رد و بدل کنید.', - blockSuccess: 'کاربر مسدود شد', - unblockSuccess: 'کاربر رفع مسدودیت شد', - loadOlderFailed: 'بارگذاری پیام‌های قدیمی‌تر ناموفق بود', - sendFailed: 'ارسال پیام ناموفق بود', - memberCount: '{count} عضو', - composerPlaceholder: 'پیام خود را بنویسید...', - sendAria: 'ارسال', - attachAria: 'پیوست فایل', - uploadImageAria: 'آپلود تصویر', - emptyMessage: 'پیام نمی‌تواند خالی باشد', - viewMembersAria: 'مشاهده اعضای گروه', - membersTitle: 'اعضای گروه', - membersLoadFailed: 'دریافت اعضا ناموفق بود', - youSuffix: ' (شما)', - chatWithSender: 'چت با {name}', - privateChatAria: 'چت خصوصی', - viewImageAria: 'مشاهده تصویر', - attachmentAlt: 'پیوست', chatAttachmentAlt: 'پیوست چت', downloadImageAria: 'دانلود تصویر', download: 'دانلود', downloadFallbackToast: 'دانلود مستقیم ممکن نبود؛ تصویر در تب جدید باز شد', - listConversationsFailed: 'دریافت مکالمات ناموفق بود', - getConversationFailed: 'دریافت مکالمه ناموفق بود', - unreadCountFailed: 'دریافت تعداد خوانده‌نشده ناموفق بود', - markReadFailed: 'علامت‌گذاری خوانده‌شده ناموفق بود', - listMessagesFailed: 'دریافت پیام‌ها ناموفق بود', - openConversationFailed: 'باز کردن گفت‌وگو ناموفق بود', - listParticipantsFailed: 'دریافت اعضای گفت‌وگو ناموفق بود', - blockUserFailed: 'مسدودکردن کاربر ناموفق بود', - unblockUserFailed: 'رفع مسدودیت ناموفق بود', - blockStatusFailed: 'بررسی وضعیت مسدودسازی ناموفق بود', - listReportReasonsFailed: 'دریافت دلایل گزارش ناموفق بود', - submitReportFailed: 'ثبت گزارش ناموفق بود', - reply: 'پاسخ', - replyPreviewAria: 'پیش‌نمایش پیام در حال پاسخ', - cancelReplyAria: 'لغو پاسخ', - replyImageLabel: 'تصویر', - replyUnavailable: 'پیام در دسترس نیست', - jumpToReplyFailed: 'یافتن پیام مرجع ناموفق بود', - loadNewerFailed: 'بارگذاری پیام‌های جدیدتر ناموفق بود', - replySenderFallback: 'کاربر', } as const diff --git a/texts/common.ts b/texts/common.ts index bae7367..e9a84b3 100644 --- a/texts/common.ts +++ b/texts/common.ts @@ -4,7 +4,6 @@ export const common = { category: 'دسته‌بندی', city: 'شهر', continue: 'ادامه', - province: 'استان', selectCity: 'انتخاب شهر', reply: 'پاسخ', description: 'توضیحات', @@ -16,52 +15,28 @@ export const common = { lastName: 'نام خانوادگی', lastNamePlaceholder: 'نام خانوادگی خود را وارد کنید', birthDatePlaceholder: 'تاریخ تولد را انتخاب کنید', - loadingEllipsis: 'در حال بارگذاری…', imageUploadFailed: 'آپلود تصویر ناموفق بود', - payment: 'پرداخت', - discountCode: 'کد تخفیف', - instagram: 'اینستاگرام', - telegram: 'تلگرام', - general: 'عمومی', - account: 'حساب کاربری', - operationFailed: 'عملیات ناموفق بود', invalidServerResponse: 'پاسخ نامعتبر از سرور', saveChanges: 'ذخیره تغییرات', - home: 'خانه', - myEvents: 'رویدادهای من', - chat: 'چت', - profile: 'پروفایل', login: 'ورود', - bottomNavAria: 'ناوبری پایین', - unreadMessagesAria: '{label}، {count} پیام خوانده‌نشده', - unreadBadgeOverflow: '۹۹+', back: 'بازگشت', retry: 'تلاش دوباره', backHome: 'بازگشت به خانه', - - loadingCards: 'در حال دریافت کارت‌ها', loadingList: 'در حال دریافت فهرست', loadingDetails: 'در حال دریافت جزئیات', loadingTable: 'در حال دریافت جدول', preparingPage: 'در حال آماده‌سازی صفحه', pleaseWait: 'لطفاً چند لحظه صبر کنید', preparingPanel: 'در حال آماده‌سازی پنل شما', - preparingEvent: 'در حال آماده‌سازی رویداد', - loadingEventDetails: 'در حال دریافت اطلاعات رویداد', - somethingWentWrong: 'مشکلی رخ داده است', tryAgainOrSupport: 'می‌توانید دوباره تلاش کنید. اگر خطا ادامه داشت، شناسه پیگیری را برای پشتیبانی ارسال کنید.', trackingId: 'شناسه پیگیری: {digest}', - accountSuspendedBanner: 'حساب کاربری شما مسدود شده است. برای پیگیری و فعال‌سازی مجدد با پشتیبانی تماس بگیرید.', contactSupport: 'تماس با پشتیبانی', - offlineBanner: 'اتصال اینترنت قطع است؛ تغییرات پس از برقراری اتصال دوباره قابل ارسال خواهند بود.', - notFoundTitle: 'یافت نشد', pageNotFound: 'صفحه‌ای که دنبال آن بودید یافت نشد.', - enterField: '{label} را وارد کنید', formIncomplete: 'اطلاعات فرم کامل نیست', formValidationDefault: 'لطفاً اطلاعات فرم را بررسی و خطاهای مشخص‌شده را برطرف کنید.', @@ -69,7 +44,6 @@ export const common = { showPassword: 'نمایش رمز عبور', hidePassword: 'پنهان‌کردن رمز عبور', toman: 'تومان', - uploadFile: 'آپلود فایل', fileMustBeAccepted: 'فایل انتخاب شده باید یک فایل مورد قبول باشد.', imageFileTooLarge: 'حجم فایل تصویری نباید بیشتر از {maxMb} مگابایت باشد.', @@ -82,7 +56,6 @@ export const common = { removeFile: 'حذف فایل', removeFileNamed: 'حذف فایل {name}', cancelUpload: 'لغو بارگذاری فایل', - imagePrepareFailed: 'آماده‌سازی تصویر ناموفق بود', imageConvertFailed: 'تبدیل تصویر ناموفق بود', imageProcessingTimedOut: 'پردازش تصویر بیش از حد طول کشید. لطفاً تصویر کوچک‌تری انتخاب کنید.', @@ -90,10 +63,8 @@ export const common = { imageEmpty: 'فایل تصویر خالی است', imageTooLarge: 'حجم تصویر نباید بیشتر از {maxMb} مگابایت باشد', imageTypeNotAllowed: 'فقط تصاویر JPEG، PNG، WebP یا HEIC مجاز هستند', - unexpectedError: 'خطای پیش‌بینی‌نشده', requestCancelled: 'درخواست لغو شد', - today: 'امروز', tomorrow: 'فردا', yesterday: 'دیروز', @@ -117,16 +88,11 @@ export const common = { birthDate: 'تاریخ تولد', selectDate: 'انتخاب تاریخ', selectTime: 'انتخاب ساعت', - invalidUrl: 'نشانی واردشده معتبر نیست.', imageAltLabel: 'توضیح تصویر (alt)', imageAltDescription: 'برای دسترس‌پذیری و سئو تصویر لازم است (مثلاً «نمای سالن کارگاه عکاسی»).', - jalaliMonths: ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'], - unsavedChanges: 'تغییرات ذخیره‌نشده دارید', noUnsavedChanges: 'تغییری برای ذخیره وجود ندارد', seoCharMinHint: ' (حداقل {min})', - listJoin: '، ', - breadcrumbAria: 'مسیر صفحه', } as const diff --git a/texts/discovery.ts b/texts/discovery.ts index 4ea1593..0d74b68 100644 --- a/texts/discovery.ts +++ b/texts/discovery.ts @@ -1,124 +1,3 @@ export const discovery = { - homeTitle: 'پیدا کردن و رزرو رویدادها و دورهمی‌های حضوری', - homeDescription: 'در قبیله رویدادها، دورهمی‌ها و تجربه‌های گروهی شهر خود را پیدا کنید، جزئیات آن‌ها را ببینید و برای شرکت آماده شوید.', - homeOgImageAlt: 'تجربه‌های گروهی و رویدادهای حضوری در قبیله', - homeEyebrow: 'تجربه‌های تازه، آدم‌های تازه', - homeHeadline: 'رویداد بعدی‌ات را پیدا کن', - homeSubhead: - 'قبیله میزبان برپایی گردهمایی‌های زنده توی شهرتونه. جایی که می‌تونی مکان رو مشخص کنی، صندلی‌ها رو بچینی و هم‌قبیله‌ای‌هات رو برای یک تجربه خاطره‌انگیز دور هم جمع کنی.', - homeCta: 'آتش رویداد خودت رو روشن کن', - homeProfileCityCta: 'ببین دور آتش شهرت چخبره', - homeHeroAriaLabel: 'دور آتش هر قبیله‌ای اتفاقای جالبی در جریانه', - homeEmptyTitle: 'هنوز توی شهر {city} قبیله‌ای بر پا نشده!', - homeEmptyDescription: 'به محض این که اولین جمع شکل بگیره، با پیامک از دودش با خبرت میکنیم.', - homeOriginStory: { - title: 'همه «ما»ها، با یه بار اول شروع شدن', - points: { - friend: 'آدمی که امروز رفیقته، یه روز غریبه بود.', - place: 'جایی که حالا پاتوقته، یه بار برای اولین بار واردش شدی.', - memory: 'خاطره‌ای که هنوز تعریفش می‌کنی، شاید از شبی شروع شد که فقط گفتی: «بریم ببینیم چه خبره.»', - }, - reflections: [ - '– اولین‌بارها موقع اتفاق افتادن خودشون رو معرفی نمی‌کنن.', - '– نمی‌دونی کدوم سلام ادامه پیدا می‌کنه،\nکدوم جمع، می‌شه جمع خودت،\nیا کدوم شب معمولی، بعداً می‌شه «اون شب».', - '– فقط می‌ری.\nتجربه می‌کنی.\nو بعضی وقت‌ها، یه چیزی از همین‌جا شروع می‌شه.', - ], - closing: 'قبیله برای همین شروع‌هاست…', - }, - homeAbilities: { - title: 'قبیله چطور کمکت می‌کنه؟', - introTitle: 'تو دلیل دور هم جمع شدن رو می‌سازی', - introDescription: 'قبیله کمک می‌کنه مسیر رسیدن آدم‌ها بهش، از اولین دعوت تا بعد از آخرین لحظه، مرتب‌تر باشه.', - create: { - title: 'رویدادت رو یک‌بار کامل معرفی کن', - details: 'زمان، مکان، ظرفیت، بلیط و هر چیزی که آدم‌ها قبل از اومدن باید بدونن، رو مشخص کن.', - page: 'قبیله از این اطلاعات یه صفحه مشخص برای رویدادت می‌سازه.', - privateAddress: 'جایی که آدم‌ها می‌تونن بفهمن قراره کجا بیان، چه چیزی در انتظارشونه و چطور جاشون رو نگه دارن.', - privateAddress2: 'حتی اگر نخوای آدرس دقیق برای همه دیده بشه، می‌تونی اون رو فقط به آدم‌هایی نشون بدی که ثبت‌نامشون تأیید شده.', - }, - publish: { - title: 'منتشرش کن؛ ادامه مسیر مشخصه', - share: 'لینک رویدادت رو هرجا که آدم‌هات هستن منتشر کن.', - booking: 'از اونجا به بعد، هرکس می‌تونه جزئیات رو ببینه، ثبت‌نام کنه و اگر رویداد پولیه، بلیطش رو بگیره.', - waitlist: 'اگر ظرفیت پر بشه، لیست انتظار کمک می‌کنه آدم‌هایی که هنوز دوست دارن بیان پشت در نمونن.', - }, - manage: { - title: 'هر لحظه بدون چه خبره', - dashboard: 'از پنل رویدادت می‌بینی چه کسایی ثبت‌نام کردن، چقدر ظرفیت مونده و چه کسایی منتظر یه جای خالی‌ان.', - attendance: 'روز رویداد هم می‌تونی حضور آدم‌ها رو ثبت کنی.', - overview: 'به‌جای اینکه وضعیت رویدادت بین پیام‌ها، فهرست‌ها و پرداخت‌های مختلف پخش باشه، همیشه جلوی چشمته.', - afterTitle: 'و وقتی رویداد تموم شد', - reviews: 'آدم‌هایی که اومدن می‌تونن تجربه‌شون رو ثبت کنن و تو هم می‌تونی جوابشون رو بدی.', - }, - community: { - group: 'اگر بخوای، بعد از پایان رویداد یه گروه برای آدم‌های همون جمع شکل می‌گیره تا گفت‌وگو کمی بیشتر ادامه پیدا کنه.', - returningPeople: 'و آدم‌هایی که اومدن، می‌تونن بخشی از جمعی باشن که برای تجربه‌های بعدی دوباره سراغش می‌ری.', - closingTitle: 'وقت بیشتری برای چیزی می‌مونه که واقعاً مهمه:', - closingDescription: 'آدم‌هایی که قراره بیان و تجربه‌ای که براشون ساختی.', - }, - images: { - create: 'نمای صفحه ساخت و جزئیات رویداد در قبیله', - publish: 'نمای انتشار و فهرست رویدادها در قبیله', - manage: 'نمای پنل مدیریت و ثبت حضور شرکت‌کنندگان رویداد', - community: 'نمای گفت‌وگوی گروه رویداد در قبیله', - }, - }, - homeClosing: { - title: 'زندگی فقط روزهایی نیست که می‌گذرند.', - description: - 'آدم‌هایی است که می‌بینی، جاهایی است که می‌روی، چیزهایی است که برای اولین بار امتحان می‌کنی و شب‌هایی است که بعدها یادت می‌مانند.', - support: 'قبیله کمک می‌کند بیشترِ این اتفاق‌ها بیفتند.', - discoverCta: 'یه چیزی پیدا کن که دلت بخواد بری', - createCta: 'یا چیزی بساز که بقیه دلشون بخواد بیان', - }, - - filtersAria: 'فیلتر رویدادها', - filterWhen: 'کی؟', - filterWhere: 'کجا؟', - filterWhat: 'چی؟', - activeFiltersLabel: 'فیلترهای فعال:', - whenRangePlaceholder: 'انتخاب بازه زمانی', - whenRange: '{from} تا {to}', - removeWhenFilter: 'حذف فیلتر زمان', - removeCityFilter: 'حذف فیلتر شهر', - removeCategoryFilter: 'حذف فیلتر دسته‌بندی', - - applyFilter: 'اعمال فیلتر', - rangeEndMustBeAfterStart: 'پایان بازه باید بعد از شروع آن باشد.', - - cityModalTitle: 'کجا؟ انتخاب شهر', - citySearchAria: 'جستجوی شهر', - cityEmpty: 'شهری پیدا نشد', - - categoryModalTitle: 'چی؟ دسته‌بندی', - categorySingleSelectHint: '(یک انتخاب)', - allCategories: 'همه دسته‌بندی‌ها', - ariaExpandCategory: 'باز کردن {name}', - ariaCollapseCategory: 'بستن {name}', - - referenceError: 'بخشی از اطلاعات فیلترها دریافت نشد. می‌توانید دوباره تلاش کنید.', - referenceIncompleteTitle: 'اطلاعات فیلترها کامل نیست', - refetchFilters: 'دریافت دوباره فیلترها', - eventsFetchFailed: 'دریافت رویدادها ناموفق بود. دوباره تلاش کنید.', - eventsFetchFailedTitle: 'دریافت رویدادها ناموفق بود', - emptyResults: 'رویدادی با این فیلترها پیدا نشد', - loadMoreFailed: 'بارگذاری بیشتر ناموفق بود', - loadingMoreAria: 'در حال دریافت رویدادهای بیشتر', - capacityFull: 'ظرفیت تکمیل شده', - viewEventAria: 'مشاهده رویداد {title}', - eventPosterAlt: 'پوستر رویداد {title}', - - categoriesTitle: 'دسته‌بندی‌ها', - categoriesDescription: 'موضوع مورد علاقه‌تان را برای پیدا کردن رویداد انتخاب کنید', - categoriesFetchFailedTitle: 'دریافت دسته‌بندی‌ها ناموفق بود', - categoriesEmpty: 'دسته‌بندی‌ای یافت نشد', - viewCategoryEvents: 'مشاهده رویدادها', - - latestEventsPrefix: 'جدیدترین رویدادهای', - homePopularListAria: 'رویدادهای پرطرفدار', - homeCategoryPreviewAria: 'جدیدترین رویدادهای دسته', - ariaPreviousEvent: 'رویداد قبلی', - ariaNextEvent: 'رویداد بعدی', - ariaActiveEvent: 'رویداد فعال: {title}', } as const diff --git a/texts/events.ts b/texts/events.ts index 8cb2589..3f5535b 100644 --- a/texts/events.ts +++ b/texts/events.ts @@ -1,114 +1,18 @@ export const events = { - // HostedEventActionsModal (keep stable) - publishRequested: 'درخواست انتشار ثبت شد؛ در صورت نیاز پس از تأیید ادمین منتشر می‌شود', - publishedSuccess: 'رویداد منتشر شد', - cancelConfirm: 'آیا از لغو این رویداد مطمئن هستید؟ همه‌ی رزروها لغو خواهند شد.', - cancelled: 'رویداد لغو شد', - completeConfirm: 'آیا این رویداد به پایان رسیده است؟', - completed: 'رویداد به‌عنوان پایان‌یافته علامت خورد', - deleteConfirm: 'آیا از حذف این رویداد مطمئن هستید؟ از لیست شما حذف می‌شود.', - deleted: 'رویداد حذف شد', - deleteFailed: 'حذف رویداد ناموفق بود', - shareLinkCopied: 'لینک {channel} رویداد کپی شد', - shareLinkCopiedGeneric: 'لینک رویداد کپی شد', - shareLinkCopyFailed: 'کپی لینک ناموفق بود', - rejectionReasonPrefix: 'دلیل رد:', - pendingAdminReview: 'در انتظار بررسی ادمین', - ariaDelete: 'حذف', - ariaClone: 'کپی', - ariaShare: 'اشتراک‌گذاری', - ariaPublish: 'انتشار', - ariaComplete: 'پایان', - ariaCancel: 'لغو', - moreActions: 'بیشتر', - moreActionsAria: 'عملیات بیشتر رویداد', - actionManageDescription: 'حضور، مالی و جزئیات', - actionEditDescription: 'ویرایش اطلاعات رویداد', - actionCloneDescription: 'ساخت پیش‌نویس مشابه', - actionShareDescription: 'کپی لینک عمومی رویداد', - actionIncreaseCapacityDescription: 'افزایش ظرفیت رویداد', - actionCompleteDescription: 'علامت‌گذاری به‌عنوان برگزارشده', - actionCancelDescription: 'لغو رویداد و همه رزروها', - actionDeleteDescription: 'حذف رویداد از لیست شما', - tabOverview: 'خلاصه', - tabAttendance: 'حضور و غیاب', - tabFinancial: 'مالی', - tabDetails: 'اطلاعات رویداد', - shareLabelInstagram: 'اینستا', - manageActions: 'مدیریت', - eventActionsTitle: 'مدیریت رویداد', - managementSections: 'بخش‌های مدیریت', - quickActions: 'عملیات رویداد', - shareLinks: 'اشتراک‌گذاری', - - // Shared free: 'رایگان', - save: 'ذخیره', - edit: 'ویرایش', nextStep: 'مرحله بعد', - saveAndContinue: 'ثبت و ادامه', - startOver: 'شروع از ابتدا', - configureEventLink: 'تنظیم لینک رویداد', - collapseDetails: 'بستن جزئیات', timeRange: '{start} تا {end}', - dateRangeMultiDay: 'از {start} تا {end}', prevStep: 'مرحله قبل', - return: 'برگشت', guest: 'مهمان', host: 'میزبان', - start: 'شروع', - end: 'پایان', - location: 'مکان', - price: 'قیمت', capacity: 'ظرفیت', title: 'عنوان', address: 'آدرس', - faqs: 'سوالات متداول', - aboutEvent: 'درباره رویداد', - locationCardTitle: 'مکان رویداد', - dateCardTitle: 'تاریخ رویداد', - timeCardTitle: 'زمان رویداد', - categoryCardTitle: 'دسته بندی', - priceCardTitle: 'هزینه رویداد', - navigateWithNeshan: 'مسیریابی با نشان', - navigateWithGoogleMaps: 'مسیریابی با گوگل مپ', - ageRestrictionChip: 'محدودیت سنی {label}', - ageChip15_19: '۱۵-۱۹ سال', - ageChip20_24: '۲۰-۲۴ سال', - ageChip25Plus: '۲۵+ سال', - peopleLimitChip: 'محدودیت', - chipFemaleOnly: 'خانم‌ها', - chipMaleOnly: 'آقایان', reviews: 'نظرات', - waitlist: 'لیست انتظار', eventEnded: 'اتمام رویداد', invalidDate: 'تاریخ نامعتبر است', - tomanSuffix: ' تومان', uploadFailed: 'آپلود ناموفق بود', personCount: '{count} نفر', - percentSymbol: '٪', - viewDetails: 'مشاهده جزئیات', - manageEventDetails: 'جزئیات مدیریت رویداد', - capacityFullBanner: 'ظرفیت رویداد تکمیل است!', - increaseCapacity: 'افزایش ظرفیت', - registeringInProgress: 'درحال ثبت نام', - registeredCount: '{count} نفر ثبت‌نام کرده‌اند', - extendRegistrationDeadline: 'تمدید مهلت ثبت‌نام', - ariaSaveEvent: 'ذخیره ایونت', - ariaDuplicateEvent: 'کپی ایونت', - ariaDeleteEvent: 'حذف ایونت', - ariaEditEvent: 'ویرایش ایونت', - ariaEditImage: 'ویرایش تصویر', - ariaEditDate: 'ویرایش تاریخ', - ariaEditTime: 'ویرایش زمان', - ariaEditPrice: 'ویرایش قیمت', - ariaEditCategory: 'ویرایش دسته‌بندی', - ariaEditLocation: 'ویرایش مکان', - ariaEditTitle: 'ویرایش عنوان', - audienceUnrestricted: 'بدون محدودیت', - audienceRestricted: 'دارد · {label}', - reservedLabel: 'رزرو شده', - guestAccessLoadFailed: 'دریافت اطلاعات مهمانان ناموفق بود.', guestAccessTitle: 'مهمانان ویژه', guestAccessLoading: 'در حال دریافت اطلاعات مهمانان…', @@ -120,138 +24,27 @@ export const events = { guestListsEmpty: 'لیست مهمانی به این رویداد متصل نشده است.', invitedGuestsTitle: 'مهمانان دعوت‌شده', invitedGuestsEmpty: 'مهمانی برای دعوت انتخاب نشده است.', - scheduleSectionTitle: 'زمان برگزاری', - addressVisibilitySectionTitle: 'حریم خصوصی آدرس', - inviteGuestsSectionTitle: 'دعوت مهمانان', - eventDetails: 'جزئیات رویداد', - skip: 'رد کردن', - - // My events - myEventsDescription: 'رزروها و رویدادهایی که میزبانی می‌کنید', - myEventsLoadFailed: 'دریافت رویدادهای شما ناموفق بود', - tabCreate: 'ساخت رویداد', - tabCreated: 'ایجاد شده‌ها', - tabRegistered: 'ثبت‌نام شده‌ها', - tabUpcoming: 'پیش رو', - tabHeld: 'برگزار شده', - tabCancelled: 'لغو شده', - tabPendingReview: 'در انتظار تایید', - tabPublished: 'منتشر شده', - tabRejected: 'لغو یا رد شده', - tabAttending: 'شرکت‌کرده', - tabHosting: 'میزبانی', - emptyListTitle: 'هنوز رویدادی اینجا نیست', - emptyListDescription: 'هر رویدادی که برایش ثبت‌نام کنی، از اینجا در دسترس خواهد بود.', - createEvent: 'ایجاد رویداد', - createIdentityTitle: 'برای ساخت رویداد، اول هویتت را تأیید کن!', - createIdentityDescription: 'برای اینکه بتوانی به‌عنوان برگزارکننده رویداد بسازی، لازم است مراحل تأیید هویت را کامل کنی.', - startIdentityProcess: 'شروع فرایند احراز هویت', - identityResubmit: 'ارسال دوباره احراز هویت', - identityRequiredToCreate: 'برای ایجاد رویداد، احراز هویت کن', - occupiedSeats: '{occupied} / {capacity} نفر اشغال‌شده', - - // Consumer event detail eventNotFound: 'رویداد یافت نشد', - eventLoadFailedTitle: 'دریافت اطلاعات رویداد ناموفق بود', - eventLoadFailedDescription: 'این رویداد یافت نشد یا دیگر در دسترس نیست.', - eventUnavailable: 'این رویداد در حال حاضر در دسترس نیست', - eventImageAlt: 'تصویر رویداد {title}', - eventThumbAlt: 'تصویر بندانگشتی رویداد {title}', - eventDefaultImageAlt: 'تصویر پیش‌فرض رویداد {title}', - addressHiddenUntilConfirmed: 'آدرس دقیق پس از تأیید رزرو نمایش داده می‌شود.', - guestRating: 'امتیاز مهمانان', - reviewCountParen: '({count} نظر)', hostProfileAria: 'پروفایل {name}', - eventHostLabel: 'میزبان این جمع', - pastEventsHeld: '{count} رویداد برگزارشده', - hostPastEventsBadge: '{count} رویداد برگزار کرده', - hostTotalGuestsBadge: '{count} نفر تا حالا در جمع‌هاش بودن', - hostParticipantFeedbackBadge: '{rating}/۵ بازخورد از شرکت‌کننده‌ها', - hostMemberSinceBadge: 'از {monthYear} در قبیله', - hostContactLinksLabel: 'راه‌های ارتباطی', - bookmarkedByCount: 'این رویداد توسط {count} نفر ذخیره شده است.', - capacityOccupiedOf: '{occupied} از {capacity} نفر', - - // Booking CTA - - // Create wizard — steps & layout - wizardStep1Label: 'معرفی رویداد', wizardStep2Label: 'زمان و مکان', - wizardStep3Label: 'بلیط و ظرفیت', - wizardStep4Label: 'بازبینی نهایی', - wizardStep1Hint: 'عنوان، دسته‌بندی، توضیحات و تصاویر رویداد', wizardStep2Hint: 'زمان‌بندی و محل برگزاری رویداد', wizardStep3Hint: 'ظرفیت، قیمت و جریمه لغو', - wizardStep4Hint: 'اطلاعات را از نگاه مهمان بررسی کن و پیش‌نویس را بساز', - wizardTitle: 'رویدادت را بساز', - wizardDescription: 'اطلاعات اصلی را مرحله‌به‌مرحله کامل کن؛ تا زمانی که پیش‌نویس را نساخته‌ای، تغییرات روی همین دستگاه نگه‌داری می‌شوند.', - wizardAutosaveOn: 'ذخیرهٔ خودکار روشن است', - wizardDraftRestoredStatus: 'پیش‌نویس بازیابی شد', - wizardCloneReadyStatus: 'کپی آمادهٔ ویرایش', - wizardAutosaveHint: 'تغییرات این فرم به‌صورت خودکار روی این دستگاه ذخیره می‌شوند.', - wizardDraftRestoredHint: 'پیش‌نویس قبلی بازیابی شد. تغییرات به‌صورت خودکار ذخیره می‌شوند.', - wizardCloneHint: 'اطلاعات رویداد قبلی کپی شد. در صورت نیاز تغییر دهید و رویداد جدید را ذخیره کنید.', - wizardCloneLoadFailed: 'بارگذاری رویداد برای کپی ناموفق بود', postersRequiredTitle: 'هر دو پوستر الزامی است', postersRequiredDescription: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) را آپلود کنید و سپس پیش‌نویس را ذخیره کنید.', - postersRequiredShort: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) را آپلود کنید.', - draftSaved: 'پیش‌نویس رویداد ذخیره شد', - createdPartialTitle: 'رویداد ایجاد شد اما برخی موارد ذخیره نشد', - createdPartialDescription: 'به صفحه جزئیات رویداد بروید و تکمیل کنید.', - draftSaveFailed: 'ذخیره پیش‌نویس ناموفق بود', - invitePreviousGuestsTitle: 'دعوت مهمانان قبلی', - invitePreviousGuestsStatus: 'پیش‌نویس ساخته شد', - invitePreviousGuestsDescription: - 'اگر مایل باشی، مهمانانی که قبلاً در رویدادهایت بوده‌اند را انتخاب کن. بعد از انتشار رویداد، پیامک دعوت برایشان ارسال می‌شود.', - selectGuestsTitle: 'انتخاب مهمانان', - selectGuestsDescription: 'این اقدام اختیاری است و جزو مراحل لازم برای ساخت رویداد نیست.', - cloneTitleSuffix: '{title} (کپی)', - wizardStepsAria: 'مراحل ایجاد رویداد', - wizardProgressTitle: 'آتشی بر قبیله بیافروز', - wizardProgressStep1: 'شروع رویداد', - wizardProgressStep2: 'تاریخ و زمان', - wizardProgressStep3: 'بلیط و ظرفیت', - wizardProgressStep4: 'تایید نهایی', - wizardProgressAria: '{progress} درصد تکمیل شده', - wizardInProgress: 'در حال تکمیل', - formatToman: '{value} تومان', - - // Step 1 - categoriesLoadFailed: 'بارگذاری دسته‌بندی‌ها ناموفق بود', mediaSectionDescription: 'پوستر عمودی و پوستر مربعی هر دو الزامی‌اند. تصاویر گالری اختیاری‌اند و کراپ نمی‌شوند.', - basicInfoSectionDescription: 'اطلاعاتی را وارد کن که به مهمان کمک می‌کند سریع موضوع رویداد را بفهمد.', - eventTitleLabel: 'عنوان رویداد', shortDescriptionLabel: 'معرفی کوتاه', - fullDescriptionLabel: 'توضیحات کامل', - advanced: '(پیشرفته)', - slugLabel: 'لینک اختصاصی(پیشرفته)', - slugHintFromTitle: 'از روی عنوان ساخته می‌شود؛ در صورت نیاز می‌توانید عوض کنید.', - slugPublicLink: 'لینک عمومی: /events/{slug}', categoryPlaceholder: 'یک دسته‌بندی را انتخاب کنید', - - // Step 2 addressVisibilityAttendeesOnly: 'فقط برای رزروکنندگان تأییدشده', addressVisibilityPublic: 'نمایش برای همه', - citiesLoadFailed: 'بارگذاری شهرها ناموفق بود', - scheduleSectionDescription: 'شروع و پایان را دقیق وارد کن؛ پس از اولین رزرو، تغییر این اطلاعات محدود می‌شود.', startsAtDateLabel: 'تاریخ شروع', startsAtTimeLabel: 'ساعت شروع', endsAtDateLabel: 'تاریخ پایان', endsAtTimeLabel: 'ساعت پایان', - venueSectionTitle: 'مکان برگزاری', - venueSectionDescription: 'شهر، آدرس و نقطهٔ دقیق محل برگزاری را مشخص کن.', - consumerVenueSectionDescription: 'آدرس و نقطهٔ دقیق محل برگزاری را مشخص کن.', - hostCityLabel: 'شهر رویداد', - hostCityMissing: 'شهر پروفایل شما مشخص نیست. ابتدا شهر را در پروفایل تکمیل کنید.', - addressPlaceholder: 'آدرس محل برگزاری', - addressHostContactHint: 'پیش‌فرض از «اطلاعات تماس» میزبان؛ در صورت نیاز ویرایش کنید.', - addressVisibilitySectionDescription: 'اگر محل خصوصی است، می‌توانی آدرس دقیق را فقط به مهمانان قطعی نشان بدهی.', addressVisibilityLabel: 'نمایش آدرس دقیق', generalAreaLabel: 'منطقه کلی ', generalAreaPlaceholder: 'مثلاً: مشهد، خیابان هفت‌تیر', mapTitle: 'موقعیت روی نقشه', mapLocked: 'موقعیت به‌خاطر رزرو فعال مهمان‌ها قفل شده است.', - reservedCapacityEditLockedHint: 'ظرفیت رزرو خارج از قبیله را از صفحه مدیریت رویداد تغییر دهید.', mapHint: 'روی نقشه کلیک کنید تا موقعیت انتخاب شود؛ آدرس به‌صورت خودکار پر می‌شود و قابل ویرایش است.', mapModalHint: 'برای تعیین نقطهٔ دقیق، نقشه را باز کنید؛ آدرس پس از انتخاب پر می‌شود و قابل ویرایش است.', mapSelectModalTitle: 'انتخاب مکان از روی نقشه', @@ -259,66 +52,19 @@ export const events = { mapKeyMissing: 'کلید نقشه (MAP_API_KEY) تنظیم نشده است', mapLoading: 'در حال بارگذاری نقشه…', mapAddressNotFound: 'آدرسی برای این نقطه پیدا نشد', - - // Step 3 - pricingSectionDescription: 'نوع ثبت‌نام را انتخاب کن؛ برای رویداد پولی، مبلغ نهایی بلیط را وارد کن.', - ticketTypeSectionTitle: 'نوع بلیط', - ticketPaid: 'با تعیین قیمت', - reservedCapacityWizardHint: - 'مهمان‌هایی که از قبل یا از کانال دیگری (مثلاً اینستاگرام) رزرو کرده‌اند. این عدد از ظرفیت قابل‌رزرو در قبیله کم می‌شود.', - audienceSectionTitle: 'محدودیت شرکت‌کنندگان', - cancellationSectionTitle: 'لغو و بازپرداخت', - moreSettings: 'تنظیمات بیشتر', - pricePerTicketLabel: 'قیمت هر بلیط (تومان)', - reservedCapacityLabel: 'ظرفیت رزرو شده', - audienceSectionDescription: 'اگر رویداد برای همه نیست، محدودیت جنسیت یا سن را انتخاب کن. پیش‌فرض آزاد است.', genderRestrictionLabel: 'محدودیت جنسی', - peopleRestrictionLabel: 'محدودیت‌افراد', ageRestrictionLabel: 'محدودیت سنی', - cancellationSectionDescription: 'شرایط لغو را پیش از رزرو به مهمان نشان می‌دهیم تا تصمیم شفافی بگیرد.', cancellationFeeLabel: 'لغو لحظه آخری — ۱ دقیقه تا ۱۲ ساعت مانده', cancellationFee12To24HoursLabel: 'لغو در آستانه برگزاری — ۱۲ تا ۲۴ ساعت مانده', cancellationFeeMoreThan24HoursLabel: 'لغو زودهنگام — بیش از ۲۴ ساعت مانده', - advancedSettingsHint: 'پیشنهاد می‌کنیم مقادیر پیش‌فرض را نگه داری', discoverableLabel: 'نمایش در جستجوی عمومی', - autoCreateGroupLabel: 'ساخت خودکار گروه چت پس از رویداد', waitlistAutoOfferLabel: 'پیشنهاد خودکار جای خالی به لیست انتظار', - sendReviewSmsLabel: 'پیامک درخواست نظر پس از رویداد', - - // Step 4 preview / FAQ - previewSectionDescription: 'این خلاصه همان اطلاعاتی است که مهمان برای تصمیم‌گیری به آن نیاز دارد.', - posterAria: 'پوستر رویداد', - noPosterAria: 'رویداد بدون پوستر', - eventCategoryFallback: 'دسته‌بندی رویداد', - readyToSave: 'آمادهٔ ذخیره است', - previewEventTitle: 'پیش‌نمایش رویداد', - checklistIntroDone: 'معرفی و دسته‌بندی تکمیل شده', - checklistScheduleDone: 'زمان و مکان مشخص است', - checklistPricingDone: 'نوع بلیط و ظرفیت تعیین شده', - checklistPosterDone: 'پوستر رویداد انتخاب شده', addQuestion: 'افزودن سوال', faqSectionDescription: 'اگر مهمان‌ها معمولاً سؤال مشخصی دارند، همین‌جا پاسخ آن را اضافه کن. این بخش اختیاری است.', questionLabel: 'سوال', - saveDraft: 'ذخیره پیش‌نویس', - - // Step 5 guests - guestsSaved: 'مهمانان انتخاب شدند. بعد از انتشار رویداد پیامک دعوت برایشان ارسال می‌شود', - guestsSaveFailed: 'ذخیره مهمانان ناموفق بود', - guestsSectionDescription: - 'از میان افرادی که قبلاً در رویدادهای شما حضور داشته‌اند انتخاب کنید. بعد از انتشار رویداد، پیامک دعوت برایشان ارسال می‌شود. این مرحله اختیاری است.', - sendInvitation: 'ذخیره مهمانان', - selectAllGuests: 'انتخاب همه', - guestSearchAria: 'جستجوی کاربران', - guestSearchPlaceholder: 'نام مهمان را جستجو کنید', - finish: 'اتمام', - - // Media uploader imageReadFailed: 'خواندن تصویر ناموفق بود', - postersBothRequired: 'پوستر عمودی (۹:۱۶) و پوستر مربعی (۱:۱) هر دو الزامی هستند.', - portraitPosterLabel: 'پوستر عمودی', portraitPosterEmpty: 'افزودن پوستر عمودی', portraitPosterHint: 'نسبت ۹:۱۶', - squarePosterLabel: 'پوستر مربعی', squarePosterEmpty: 'افزودن پوستر مربعی', squarePosterHint: 'نسبت ۱:۱', galleryTitle: 'تصاویر گالری', @@ -336,21 +82,13 @@ export const events = { cropPortraitHint: 'پوستر کارت‌های عمودی با نسبت ۹:۱۶ برش داده می‌شود.', pickOtherSquarePoster: 'انتخاب عکس دیگر برای پوستر مربعی', change: 'تغییر', - - // New / identity gate - createEventPageTitle: 'ایجاد رویداد جدید', - nationalIdImageLabel: 'تصویر کارت ملی', - - // Edit editEventTitle: 'ویرایش', invalidEventId: 'شناسه رویداد نامعتبر است', eventLoadFailed: 'بارگذاری رویداد ناموفق بود', eventUpdated: 'رویداد به‌روزرسانی شد', eventUpdateFailed: 'ذخیره تغییرات ناموفق بود', - editOwnerOnly: 'فقط سازنده‌ی رویداد می‌تواند آن را ویرایش کند.', editStatusBlocked: 'این رویداد در وضعیت «{status}» دیگر قابل ویرایش نیست.', capacityBelowOccupied: 'ظرفیت نمی‌تواند کمتر از صندلی‌های اشغال‌شده ({min}) باشد', - capacityMinOccupiedHint: 'با توجه به رزروهای فعال، ظرفیت نمی‌تواند کمتر از {min} باشد.', basicInfoCardTitle: 'اطلاعات پایه', basicInfoCardDescription: 'عنوان، دسته‌بندی و توضیحات اصلی رویداد', slugLockedHint: 'پس از انتشار، تغییر اسلاگ تا تایید ادمین روی لینک عمومی اعمال نمی‌شود.', @@ -362,16 +100,6 @@ export const events = { autoCreateGroupAfterEndLabel: 'ساخت خودکار گروه چت بعد از پایان رویداد', sendReviewSmsAfterEndLabel: 'ارسال پیامک درخواست نظر بعد از پایان رویداد', editLockedWithBookings: 'این رویداد {count} رزرو فعال دارد؛ زمان، مکان، قیمت، جریمه لغو، محدودیت جنسی/سنی و تنظیمات رفتاری قفل شده‌اند.', - editRevisionConfirmTitle: 'نیاز به بررسی مجدد ادمین', - editRevisionConfirmMessage: - 'با ذخیره، تغییرات برای بررسی ادمین ارسال می‌شود. تا زمان تایید، مهمان‌ها همان نسخهٔ فعلی را می‌بینند. پس از تایید ادمین، رویداد با اطلاعات جدید فوراً منتشر می‌شود و نیازی به تایید دوباره شما نیست.', - editRevisionConfirmAccept: 'ارسال برای بررسی', - editRevisionSubmitted: 'ویرایش برای بررسی ادمین ارسال شد', - editRevisionPendingBanner: 'یک ویرایش در انتظار تایید ادمین دارید. ذخیرهٔ دوباره جایگزین آن می‌شود.', - editRevisionWithdraw: 'انصراف از ویرایش', - editRevisionWithdrawn: 'ویرایش لغو شد', - editRevisionWithdrawFailed: 'لغو ویرایش ناموفق بود', - editSaved: 'تغییرات ذخیره شد', editMediaCardTitle: 'پوستر و تصاویر', editFaqCardTitle: 'سوالات متداول', reservedCapacityEditHint: 'ظرفیت رزرو شده: {count} نفر (مهمان‌های خارج از قبیله). تغییر این عدد از صفحه مدیریت ایونت انجام می‌شود.', @@ -381,148 +109,15 @@ export const events = { revisionApprove: 'تایید و اعمال ویرایش', revisionReject: 'رد ویرایش', revisionRejectReasonLabel: 'دلیل رد', - revisionNoPending: 'ویرایش معلقی برای این رویداد نیست', revisionLoadFailed: 'بارگذاری ویرایش ناموفق بود', - - // Audience genderOpen: 'آزاد برای عموم', genderFemaleOnly: 'خانم‌ها', genderMaleOnly: 'آقایان', age15_19: '۱۵-۱۹ سال', age20_24: '۲۰-۲۴ سال', age25Plus: '+۲۵', - - // Manage dashboard - manageTitle: 'مدیریت ایونت', - manageLoadFailedTitle: 'دریافت اطلاعات مدیریتی ناموفق بود', - manageLoadFailedDescription: 'اطلاعات مدیریتی این ایونت در دسترس نیست یا شما مالک آن نیستید.', - shareLinkCopiedShort: 'لینک {channel} کپی شد', - reservedCapacityMin: 'ظرفیت رزرو شده باید صفر یا بیشتر باشد', - reservedCapacityUpdated: 'ظرفیت رزرو شده به‌روز شد', - reservedCapacityUpdateFailed: 'تغییر ظرفیت رزرو شده ناموفق بود', - preview: 'پیش‌نمایش', - ariaCapacityFill: 'درصد تکمیل ظرفیت', - activeRegistrations: '{count} ثبت‌نام فعال', remainingCapacity: '{count} نفر باقی‌مانده', - reservedCapacityDisplay: 'ظرفیت رزرو شده: {count}', - reservedCapacityHelp: - 'مهمان‌های خارج از قبیله (مثلاً اینستاگرام). صندلی‌های در حال پرداخت هم اشغال‌شده‌اند؛ حداکثر قابل‌تنظیم {max} است.', - changeReservedCapacity: 'تغییر ظرفیت رزرو', - manageCapacity: 'مدیریت ظرفیت', - currentAvailableCapacity: 'ظرفیت خالی فعلی: {count} نفر', - capacityValuesInvalid: 'ظرفیت‌ها باید عدد صحیح صفر یا بزرگ‌تر باشند', - capacityUpdated: 'ظرفیت رویداد به‌روز شد', - capacityUpdateFailed: 'تغییر ظرفیت رویداد ناموفق بود', - manageSectionsAria: 'بخش‌های مدیریت ایونت', - confirmedRegistrations: 'ثبت‌نام قطعی', - ofCapacityHint: 'از {count} نفر ظرفیت', - checkedIn: 'حاضر شده', - awaitingCheckInHint: '{count} نفر در انتظار', - waitlistLabel: 'فهرست انتظار', - pendingPaymentHint: '{count} پرداخت ناقص', - netRevenue: 'درآمد خالص', - bookmarksHint: '{count} ذخیره', - registrationStatusTitle: 'وضعیت ثبت‌نام‌ها', - statusConfirmed: 'قطعی', - statusPendingPayment: 'پرداخت ناقص', - statusRefunded: 'بازپرداخت', - statusExpired: 'منقضی', - statusNoShow: 'غایب', - trafficLinksTitle: 'لینک‌های جذب و منبع ورود', - trafficLinksHint: 'برای شمارش بازدید و رزرو، لینک اختصاصی هر کانال را در اینستاگرام یا تلگرام بگذارید.', - copyInstagramLink: 'کپی لینک اینستاگرام', - copyTelegramLink: 'کپی لینک تلگرام', - instagramUniqueVisits: 'بازدید یکتا از اینستاگرام', - instagramConfirmedBookings: 'رزرو قطعی از اینستاگرام', - telegramUniqueVisits: 'بازدید یکتا از تلگرام', - telegramConfirmedBookings: 'رزرو قطعی از تلگرام', - editInfo: 'ویرایش اطلاعات', - viewEventPage: 'مشاهده صفحه ایونت', eventIsFreeTitle: 'این ایونت رایگان است', - eventIsFreeFinancialHint: 'برای ایونت رایگان تراکنش درآمدی ایجاد نمی‌شود.', - grossSales: 'فروش ناخالص', - platformCommission: 'کمیسیون پلتفرم', - guestRefunds: 'بازپرداخت مهمان', - settlementStatusTitle: 'وضعیت تسویه درآمد', - heldAmount: 'در انتظار آزادسازی', - availableAmount: 'آماده تسویه', - settledAmount: 'تسویه‌شده', - registeredInfoTitle: 'اطلاعات ثبت‌شده', - slugUrlLabel: 'نشانی اینترنتی', - coordinatesLabel: 'مختصات', - cancellationFeeField: 'جریمه لغو', - discoverableField: 'نمایش عمومی', - addressVisibilityField: 'نمایش آدرس', - addressAttendeesOnlyWithArea: 'فقط رزروکنندگان تأییدشده (منطقه کلی: {area})', - autoCreateGroupField: 'ساخت خودکار گروه چت', - waitlistAutoOfferField: 'پیشنهاد خودکار لیست انتظار', - sendReviewSmsField: 'پیامک درخواست نظر بعد از پایان', - discoveryPlacementsField: 'جایگاه‌های نمایش', - noSpecialPlacement: 'جایگاه ویژه‌ای ندارد', - publishedAtField: 'زمان انتشار', - notPublished: 'منتشر نشده', - createdAtField: 'زمان ایجاد', - mediaTitle: 'رسانه‌ها ({count})', - mediaAlt: 'رسانه {title}', - noMedia: 'رسانه‌ای ثبت نشده است.', - faqsTitle: 'سوالات متداول ({count})', - noFaqs: 'سوالی ثبت نشده است.', - changeReservedCapacityTitle: 'تغییر ظرفیت رزرو شده', - reservedCapacityModalHint: 'ثبت‌نام فعال (قطعی و در حال پرداخت): {booked} — حداکثر ظرفیت رزرو شده: {max}', - - // Attendance - filterAll: 'همه', - filterNotCheckedIn: 'در انتظار حضور', - bookingStatusPendingPayment: 'پرداخت تکمیل نشده', - bookingStatusExpired: 'منقضی شده', - bookingStatusRefunded: 'بازپرداخت شده', - attendeesLoadFailed: 'دریافت فهرست مهمانان ناموفق بود.', - checkInSuccess: 'حضور مهمان با موفقیت ثبت شد', - checkInFailed: 'ثبت حضور ناموفق بود', - checkInFailedDescription: 'وضعیت ثبت‌نام مهمان را دوباره بررسی کنید.', - attendanceSearchHint: 'نام، نام خانوادگی، شماره موبایل یا کد رزرو مهمان را جست‌وجو کنید.', - attendanceSearchAria: 'جست‌وجوی مهمان با نام یا شماره موبایل', - attendanceSearchPlaceholder: 'مثلاً سارا احمدی یا 0912...', - attendanceFilterAria: 'فیلتر وضعیت مهمانان', - ariaLoadingAttendees: 'در حال دریافت مهمانان', - noAttendeeSearch: 'مهمانی با این نام یا شماره ثبت‌نام نکرده است.', - noAttendeeInFilter: 'مهمان موردنظر در این فیلتر پیدا نشد.', - noAttendeeInStatus: 'مهمانی در این وضعیت وجود ندارد.', - searchPhoneHint: 'شماره را با 09، 98 یا ارقام فارسی هم می‌توانید وارد کنید.', - tryFilterAllHint: 'برای بررسی ثبت‌نام، فیلتر «همه» را انتخاب کنید.', - checkInRecorded: 'حضور ثبت شده', - guestUnnamed: 'مهمان بدون نام', - attendanceStatusLabel: 'وضعیت حضور', - present: 'حاضر', - notYetEntered: 'هنوز وارد نشده', - notAllowed: 'غیرمجاز', - checkInGuest: 'ثبت حضور مهمان', - checkInOnlyBeforeEnd: 'ثبت حضور فقط تا پیش از پایان رسمی ایونت فعال است.', - previous: 'قبلی', - next: 'بعدی', - pageOf: 'صفحه {page} از {total}', - resultsCount: '{count} نتیجه', - searchResultsFor: 'نتیجه جست‌وجوی «{query}»', - - // Waitlist manage - waitlistStatusNotified: 'پیشنهاد ارسال‌شده', - waitlistStatusAccepted: 'پذیرفته‌شده', - waitlistStatusConverted: 'تبدیل به رزرو', - waitlistStatusCancelled: 'انصراف داده', - waitlistLoadFailed: 'دریافت فهرست انتظار ناموفق بود.', - offerSeatFailed: 'پیشنهاد جای خالی ناموفق بود', - offerSeatSuccess: 'جای خالی به این نفر پیشنهاد شد', - waitlistLoading: 'در حال بارگذاری فهرست انتظار...', - waitlistAutoOnHint: - 'پیشنهاد خودکار روشن است: با آزادشدن جای خالی، به نفر اول صف پیشنهاد داده می‌شود. در صورت نیاز می‌توانید همین‌جا شخص دیگری را دستی انتخاب کنید.', - waitlistAutoOffHint: - 'پیشنهاد خودکار خاموش است: با آزادشدن جای خالی هیچ پیشنهادی به‌صورت خودکار ارسال نمی‌شود؛ باید از همین‌جا یک نفر را برای دریافت جای خالی انتخاب کنید.', - waitlistEmpty: 'فهرست انتظار خالی است.', - waitlistMember: 'عضو صف انتظار', - waitlistJoinedAt: 'عضویت: {date}', - offerSeat: 'پیشنهاد جای خالی', - - // Discounts percentOff: '{value}٪ تخفیف', amountOff: '{value} تخفیف', discountTypePercent: 'درصدی', @@ -572,10 +167,7 @@ export const events = { deactivated: 'غیرفعال شده', redeemedCodesTitle: 'کدهای استفاده‌شده ({count})', payableAmount: 'پرداختی: {amount}', - discountCodeInvalid: 'کد تخفیف معتبر نیست', discountUpdateFailed: 'به‌روزرسانی کد تخفیف ناموفق بود', - - // Services categoriesListFailed: 'دریافت لیست دسته‌بندی‌ها ناموفق بود', categoryCreateFailed: 'ایجاد دسته‌بندی ناموفق بود', categoryUpdateFailed: 'ویرایش دسته‌بندی ناموفق بود', diff --git a/texts/identity.ts b/texts/identity.ts deleted file mode 100644 index 2555546..0000000 --- a/texts/identity.ts +++ /dev/null @@ -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 diff --git a/texts/index.ts b/texts/index.ts index 619b5b6..65de87f 100644 --- a/texts/index.ts +++ b/texts/index.ts @@ -1,34 +1,26 @@ import { auth } from './auth' import { blog } from './blog' -import { bookings } from './bookings' import { chats } from './chats' import { common } from './common' import { discovery } from './discovery' import { errors } from './errors' import { events } from './events' -import { identity } from './identity' -import { notifications } from './notifications' import { profile } from './profile' import { reviews } from './reviews' import { seo } from './seo' import { support } from './support' import { status } from './status' import { validation } from './validation' -import { wallet } from './wallet' export const texts = { common, errors, auth, discovery, - bookings, chats, events, profile, - wallet, - identity, support, - notifications, reviews, blog, seo, diff --git a/texts/notifications.ts b/texts/notifications.ts deleted file mode 100644 index fdd817f..0000000 --- a/texts/notifications.ts +++ /dev/null @@ -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 diff --git a/texts/profile.ts b/texts/profile.ts index b2d122a..e85cdc8 100644 --- a/texts/profile.ts +++ b/texts/profile.ts @@ -1,147 +1,4 @@ export const profile = { - title: 'پروفایل من', - description: 'حساب، کیف پول و تنظیمات شما', - loadFailed: 'دریافت اطلاعات پروفایل ناموفق بود', - avatarAlt: 'تصویر پروفایل', - guestFallbackName: 'کاربر گرامی', - walletExpandAria: 'باز کردن کیف پول', - walletCollapseAria: 'بستن کیف پول', - walletDeposit: 'واریز به کیف پول', - walletWithdraw: 'برداشت مستقیم', - walletSeeAll: 'مشاهده همه تراکنش‌ها', - walletEmptyTransactions: 'هنوز تراکنشی ثبت نشده است', - identityBannerDescription: 'برای برگزاری رویداد لازمه که هویتتو تایید کنی!', - identityBannerPending: 'درخواست احراز هویتت در حال بررسی است.', - identityBannerRejected: 'احراز هویت رد شده؛ برای ارسال دوباره اینجا بزن.', - editProfileAria: 'ویرایش پروفایل', - moreMenuAria: 'منوی بیشتر', - moreMenuTitle: 'تنظیمات و دسترسی‌ها', - followersCount: '{count} دنبال‌کننده', - metaSeparator: ' • ', - bookmarksSectionTitle: 'ذخیره‌شده‌ها', - bookmarksSectionCount: '{count} رویداد', - bookmarksSectionEmpty: 'رویداد ذخیره‌شده‌ای ندارید.', - connectionsTitle: 'ارتباط‌های من', - connectionsFollowingCount: '{count} دنبال‌شده', - connectionsEmpty: 'هنوز ارتباطی ثبت نشده است.', - seeAll: 'مشاهده همه', - identityStatusForHosting: 'وضعیت احراز هویت برای میزبانی', - logout: 'خروج', - logoutConfirm: 'برای خروج مطمئن هستید؟', - logoutFailed: 'خروج ناموفق بود', - - menuBookmarks: 'رویدادهای ذخیره‌شده', - menuAccount: 'اطلاعات حساب کاربری', - menuPublicProfile: 'پروفایل عمومی', - menuContact: 'اطلاعات تماس', - menuSocial: 'شبکه‌های اجتماعی', - menuIdentity: 'احراز هویت میزبان', - menuSupport: 'پشتیبانی و درخواست‌های من', - menuFollowing: 'دنبال‌شده‌ها', - notificationsUnreadAria: 'اعلان‌ها، {count} خوانده‌نشده', - unreadOverflow: '۹+', - - editSettingsAria: 'بخش‌های ویرایش پروفایل', - contactTab: 'تماس و شبکه‌ها', - usernamePlaceholder: 'نام کاربری', - accountLoadFailed: 'دریافت اطلاعات حساب ناموفق بود', - editAvatarAria: 'ویرایش تصویر پروفایل', - uploadAvatarAria: 'آپلود تصویر پروفایل', - mobileLabel: 'موبایل', - roleLabel: 'نقش', - personalInfoTitle: 'اطلاعات شخصی', - personalInfoDescription: 'اطلاعاتی که در پروفایل و ارتباط با سایر کاربران نمایش داده می‌شود.', genderOther: 'سایر', - provincePlaceholder: 'انتخاب استان', - bio: 'بیوگرافی', - bioPlaceholder: 'کمی درباره خودتان بنویسید', - avatarUpdated: 'تصویر پروفایل بروزرسانی شد', - avatarUploadFailed: 'آپلود تصویر پروفایل ناموفق بود', - accountUpdated: 'اطلاعات حساب بروزرسانی شد', - updateFailed: 'به‌روزرسانی ناموفق بود', - - contactLoadFailed: 'دریافت اطلاعات تماس ناموفق بود', - addressSaved: 'آدرس تماس ذخیره شد', - saveFailed: 'ذخیره ناموفق بود', - saveAddress: 'ذخیره آدرس', - contactSectionDescription: 'حالت روشن: روی پروفایل عمومی نمایش داده می‌شود؛ حالت خاموش: نمایش داده نمی‌شود.', - defaultVenueAddress: 'آدرس ثابت برگزاری', - defaultVenueAddressPlaceholder: 'مثلاً تهران، خیابان ولیعصر، پلاک ۱', - networksAndContact: 'شبکه‌ها و راه‌های تماس', - noLinksYet: 'هنوز لینکی ثبت نشده است.', - linkValueRequired: 'مقدار لینک را وارد کنید', - linkAdded: 'لینک تماس اضافه شد', - linkUpdated: 'لینک بروزرسانی شد', - linkDeleted: 'لینک حذف شد', - linkPublicShown: 'لینک روی پروفایل عمومی نمایش داده می‌شود', - linkPublicHidden: 'لینک روی پروفایل عمومی نمایش داده نمی‌شود', - add: 'افزودن', - contactChannelAria: 'کانال تماس', - contactLinkValueAria: 'مقدار لینک تماس', - contactValueAria: 'مقدار {channel}', - allChannelsUsed: 'همه کانال‌های مجاز ثبت شده‌اند.', - genericError: 'خطایی رخ داد', - - channelTwitter: 'ایکس (توییتر)', - channelLinkedin: 'لینکدین', - channelLinkedinPlaceholder: 'نام کاربری پروفایل', - channelYoutube: 'یوتیوب', - channelYoutubePlaceholder: 'نام کانال', - channelWebsite: 'وب‌سایت', - channelWebsitePlaceholder: 'https://example.com', - channelSupportPhone: 'تلفن پشتیبانی', - channelSupportPhonePlaceholder: '0912… یا 98912…', - - followingTitle: 'دنبال‌کردن‌ها', - followersTab: 'دنبال‌کنندگان', - followingLoadFailed: 'دریافت میزبان‌ها ناموفق بود', - followingEmpty: 'هنوز هیچ میزبانی را دنبال نکرده‌اید', - followersLoadFailed: 'دریافت دنبال‌کنندگان ناموفق بود', - followersEmpty: 'هنوز هیچ دنبال‌کننده‌ای ندارید', - - bookmarksLoadFailed: 'دریافت رویدادهای ذخیره‌شده ناموفق بود.', - bookmarksEmpty: 'هنوز رویدادی برای بعد ذخیره نکرده‌اید.', - - follow: 'دنبال کردن', - following: 'دنبال‌شده', - followStatusLoadingAria: 'در حال بررسی وضعیت دنبال‌کردن', - notifyOnAria: 'روشن‌کردن اعلان رویداد جدید', - notifyOffAria: 'خاموش‌کردن اعلان رویداد جدید', - unfollowTitle: 'لغو دنبال‌کردن', - unfollowConfirm: 'این میزبان را دیگر دنبال نکنم؟', - unfollowAccept: 'بله، لغو کن', - unfollowReject: 'نه', - - bookmarkSaved: 'برای بعد ذخیره شد', - bookmarkRemoved: 'از ذخیره‌شده‌ها حذف شد', - bookmarkToggleFailed: 'تغییر وضعیت ذخیره‌سازی ناموفق بود', - bookmarkSaveAria: 'ذخیره برای بعد', - bookmarkRemoveAria: 'حذف از ذخیره‌شده‌ها', - - organizerAvatarAlt: 'تصویر {name}', - verifiedHost: 'میزبان تأییدشده', - followerStat: 'دنبال‌کننده', - hostedEventsStat: 'رویداد برگزارشده', - reviewsCount: '{count} نظر', - ratingStat: 'امتیاز', - aboutTitle: 'درباره {name}', - sendMessage: 'ارسال پیام', - upcomingEvents: 'رویدادهای پیش رو', - pastEvents: 'رویدادهای گذشته', - noUpcomingEvents: 'فعلاً رویداد جدیدی منتشر نشده است.', - noPastEvents: 'هنوز رویداد گذشته‌ای ثبت نشده است.', - - fetchProfileFailed: 'دریافت پروفایل ناموفق بود', - updateProfileFailed: 'به‌روزرسانی پروفایل ناموفق بود', completeProfileFailed: 'تکمیل پروفایل ناموفق بود', - fetchFollowStatusFailed: 'دریافت وضعیت دنبال‌کردن ناموفق بود', - followFailed: 'دنبال‌کردن میزبان ناموفق بود', - unfollowFailed: 'لغو دنبال‌کردن ناموفق بود', - updateNotifyFailed: 'بروزرسانی تنظیمات اعلان ناموفق بود', - fetchFollowingFailed: 'دریافت لیست دنبال‌شده‌ها ناموفق بود', - fetchFollowersFailed: 'دریافت لیست دنبال‌کنندگان ناموفق بود', - fetchContactLinksFailed: 'دریافت لینک‌های تماس ناموفق بود', - addContactLinkFailed: 'افزودن لینک تماس ناموفق بود', - updateContactLinkFailed: 'به‌روزرسانی لینک تماس ناموفق بود', - deleteContactLinkFailed: 'حذف لینک تماس ناموفق بود', } as const diff --git a/texts/reviews.ts b/texts/reviews.ts index 05e38b6..f830cc8 100644 --- a/texts/reviews.ts +++ b/texts/reviews.ts @@ -1,51 +1,8 @@ export const reviews = { - // Shared - avgFromCount: '{rating} از {count} نظر', loadMore: 'نمایش نظرات بیشتر', - loadMoreAlt: 'نمایش نظرهای بیشتر', hostReply: 'پاسخ میزبان', - yourReply: 'پاسخ شما', ratingOnly: 'فقط امتیاز ثبت شده است.', loading: 'در حال بارگذاری نظرات…', - - // OrganizerReviewsSection - guestReviewsTitle: 'نظرات مهمانان', - organizerEmptyHint: 'هنوز نظری برای این میزبان ثبت نشده است.', - organizerEmptyTitle: 'نظری ثبت نشده', - - // EventReviewsSection - peopleSayTitle: 'آدم‌هایی که اینجا بودن چی می‌گن؟', - eventEmptyTitle: 'هنوز کسی تجربه‌ش رو نگفته...', - eventEmptyHint: 'اولین نفر باش که بعد از این جمع، داستان این تجربه رو تعریف می‌کنه.', - emptyBeFirst: 'اولین نفری باشید که برای این رویداد امتیاز می‌دهد.', - emptyAfterEvent: 'پس از اتمام رویداد، نظرات مهمانان اینجا نمایش داده می‌شود.', - emptyTitle: 'نظری وجود ندارد', - shareExperienceTitle: 'از تجربه‌ات بگو', - shareExperiencePlaceholder: 'چی دوست داشتی؟ چه چیزی این جمع رو خاص کرد؟', - submitReview: 'ثبت نظر', - - // ReviewModal - selectRating: 'لطفاً امتیاز را انتخاب کنید', - submitted: 'نظر شما ثبت شد', - createTitle: 'ثبت نظر و امتیاز', - yourRating: 'امتیاز شما', - bodyLabel: 'نظر (اختیاری)', - bodyPlaceholder: 'اگر مایلید تجربه خود را بنویسید…', - ratingOnlyHint: 'می‌توانید فقط امتیاز بدهید و متن را خالی بگذارید.', - - // StarRating - ratingValueAria: 'امتیاز {value} از ۵', - heartAria: '{star} قلب', - - // EventReviewsPanel (host manage) - replyBodyRequired: 'متن پاسخ نمی‌تواند خالی باشد', - replySaved: 'پاسخ ذخیره شد', - replyDeleted: 'پاسخ حذف شد', hostEmptyDescription: 'وقتی مهمانان بعد از اتمام رویداد نظر بدهند، اینجا نمایش داده می‌شود.', hostEmptyTitle: 'هنوز نظری ثبت نشده', - editReply: 'ویرایش پاسخ', - saveReply: 'ذخیره پاسخ', - deleteReply: 'حذف پاسخ', - replyModalTitle: 'پاسخ به نظر', - replyBodyLabel: 'متن پاسخ', } as const diff --git a/texts/seo.ts b/texts/seo.ts index 32f5dc1..1b86d25 100644 --- a/texts/seo.ts +++ b/texts/seo.ts @@ -1,370 +1,3 @@ export const seo = { - titleTemplate: '%s | قبیله', - defaultDescription: 'پلتفرم رویدادها و تجربه‌های گروهی قبیله', discoveryDescription: 'رویدادهای گروهی و تجربه‌های محلی را در قبیله پیدا کنید، رزرو کنید و شرکت کنید.', - citiesBreadcrumb: 'شهرها', - shellFooterTagline: 'قبیله؛ جایی برای تجربه‌های گروهی واقعی', - organizerProfileDescription: 'پروفایل {name} در قبیله', - filterByCity: 'فیلتر بر اساس شهر ←', - filterByCategory: 'فیلتر بر اساس دسته‌بندی ←', - categoryEventsHeading: 'رویدادهای {categoryName}', - categoryImageAlt: 'تصویر دسته‌بندی {categoryName}', - categoryEmpty: 'در حال حاضر رویداد فعالی در {categoryName} ثبت نشده است', - categoryGuideHeading: 'راهنمای انتخاب {categoryName}', - categoryTehranLink: 'رویدادهای {categoryName} در تهران ←', - cityEventsHeading: 'رویدادها در {cityName}', - cityIntro: - 'برنامه‌ها، ایونت‌ها و دورهمی‌های {cityName} را در قبیله ببینید؛ زمان، دسته‌بندی و جزئیات هر رویداد پیش از ثبت‌نام در دسترس شماست.', - cityEmpty: 'در حال حاضر رویداد فعالی در {cityName} ثبت نشده است.', - cityMetaDescription: 'رویدادها و تجربه‌های گروهی در {cityName} را در قبیله ببینید و رزرو کنید.', - cityFaqQuestion: 'چطور رویدادهای {cityName} را پیدا کنم؟', - cityFaqAnswer: - 'رویدادهای فعال این صفحه مرتب به‌روز می‌شوند. برای پیدا کردن برنامه‌ی مناسب، دسته‌بندی موردنظرتان را انتخاب کنید و صفحه‌ی هر رویداد را برای زمان، محل برگزاری و شرایط ثبت‌نام بررسی کنید.', - comboHeading: 'رویدادهای {categoryName} در {cityName}', - comboMetaDescription: 'رویدادهای {categoryName} در {cityName} را در قبیله پیدا کنید و رزرو کنید.', - comboEmpty: 'فعلاً رویداد {categoryName} در {cityName} ثبت نشده است.', - comboAllCityEvents: 'همه رویدادهای {cityName}', - comboAllCategoryEvents: 'همه رویدادهای {categoryName}', - categoryHub: { - title: 'رویدادها بر اساس دسته‌بندی', - description: 'رویدادها و تجربه‌های گروهی را بر اساس دسته‌بندی در قبیله پیدا کنید.', - collectionDescription: 'فهرست دسته‌بندی‌های فعال رویداد در قبیله.', - subtitle: 'موضوع مورد علاقه‌تان را انتخاب کنید و رویدادهای مرتبط را ببینید.', - empty: 'فعلاً دسته‌بندی فعالی ثبت نشده است', - loading: 'در حال دریافت دسته‌بندی‌ها', - }, - cityHub: { - title: 'رویدادها بر اساس شهر', - description: 'رویدادها و تجربه‌های گروهی را بر اساس شهر خود در قبیله پیدا کنید.', - collectionDescription: 'فهرست شهرهایی که رویداد فعال در قبیله دارند.', - empty: 'فعلاً رویداد فعالی در هیچ شهری ثبت نشده است.', - loading: 'در حال دریافت رویدادهای شهر', - }, - manifest: { - shortcutChats: 'گفتگوها', - }, - home: { - aboutParagraph1: - 'قبیله بستری برای کشف رویدادهای حضوری و تجربه‌های گروهی است. می‌توانید برنامه‌های متناسب با علاقه و شهر خود را پیدا کنید، اطلاعات رویداد و میزبان را بررسی کنید و برای شرکت در آن آماده شوید.', - aboutParagraph2: - 'از دورهمی‌های فرهنگی و کتاب‌خوانی تا کارگاه‌های هنری، برنامه‌های ورزشی، بازی‌های گروهی و تجربه‌های آموزشی، هدف قبیله این است که مسیر آشنایی با تجربه‌های واقعی و آدم‌های هم‌علاقه را ساده‌تر کند.', - exploreHeading: 'از کجا شروع کنم؟', - hostHeading: 'رویداد خودت را در قبیله برگزار کن', - hostBody: 'اگر برگزارکننده، مدرس یا صاحب یک فضای مستقل هستید، با امکانات میزبانی قبیله و مراحل انتشار رویداد آشنا شوید.', - faqHeading: 'سؤال‌هایی که ممکنه قبل از شروع داشته باشی', - faqRelatedNavAria: 'پیوندهای مرتبط با سؤالات متداول', - discoveryLinks: [ - { - description: 'موضوع مورد علاقه‌ات را انتخاب کن و تجربه‌های مرتبط را ببین.', - href: '/category', - }, - { - description: 'برنامه‌ها و تجربه‌های گروهی نزدیک خودت را پیدا کن.', - href: '/city', - }, - { - title: 'سؤالات متداول', - description: 'رزرو، پرداخت، لغو و میزبانی را کوتاه و مستقیم ببین.', - href: '/faq', - }, - { - title: 'میزبان شوید', - description: 'امکانات برگزارکنندگان و مسیر شروع میزبانی در قبیله.', - href: '/become-a-host', - }, - { - title: 'راهنمای میزبانی', - description: 'چک‌لیست قدم‌به‌قدم طراحی، قیمت‌گذاری و اجرای رویداد.', - href: '/host-guide', - }, - { - title: 'راهنماهای مجله قبیله', - description: 'برای انتخاب بهتر، شرکت در رویداد و میزبانی آماده‌تر شو.', - href: '/blog', - }, - ], - faqs: [ - { - question: 'چطور توی یه رویداد شرکت کنم؟', - answer: - 'رویدادی که دوست داری رو از صفحه اصلی، دسته‌بندی یا شهرها پیدا کن، جزئیات و شرایط لغوش رو بخون و دکمه رزرو رو بزن. برای رویداد پولی، بعد از پرداخت موفق جات قطعی می‌شه. راهنمای کوتاه را در سؤالات متداول ببین.', - }, - { - question: 'اگه ظرفیت رویداد پر شده باشه چی؟', - answer: - 'اگه لیست انتظار فعال باشه، می‌تونی اسمت رو ثبت کنی. اگه جایی خالی بشه، فرصت رزرو بهت پیشنهاد می‌شه. جزئیات بیشتر در سؤالات متداول آمده است.', - }, - { - question: 'اگه نتونم برم چی می‌شه؟', - answer: - 'از بخش «رویدادهای من» رزروت رو لغو کن. مبلغ قابل بازپرداخت، قبل از تأیید لغو و بر اساس شرایط لغو همون رویداد بهت نشون داده می‌شه. چارچوب کلی در صفحه لغو و بازگشت وجه است.', - }, - { - question: 'خودم هم می‌تونم یه رویداد بسازم؟', - answer: 'آره. معرفی امکانات در «میزبان شوید» است و چک‌لیست عملی در «راهنمای میزبانی». از لینک‌های زیر همین بخش شروع کن.', - }, - { - question: 'ساخت رویداد هزینه داره؟', - answer: - 'ساخت و منتشر کردن رویداد هزینه‌ای نداره. اگر رویدادت پولی باشه، کارمزد فروش از مبلغ بلیط‌ها کم می‌شه و جزئیات مالی در پنل رویداد نشون داده می‌شه. مسیر شروع: میزبان شوید.', - }, - { - question: 'پول بلیط‌ها چطور به دستم می‌رسه؟', - answer: - 'بعد از نهایی شدن درآمد رویداد و کسر بازپرداخت‌ها و موارد مالی، مبلغ قابل‌تسویه از مسیر تسویه‌های میزبانی به حساب بانکی تأییدشده پرداخت می‌شه. مسیر میزبانی را در «میزبان شوید» ببین.', - }, - ], - }, - categoryLandings: { - 'arts-culture': { - intro: - 'در این صفحه اجرا، موسیقی، نمایش، نمایشگاه و تجربه‌های هنری و فرهنگی حضوری قبیله گرد می‌آیند. برنامه را بر اساس سبک، مدت، ظرفیت و نقش خودتان — تماشاگر یا سازنده — مقایسه کنید و از همان صفحه رویداد رزرو کنید.', - highlights: [ - 'مقایسه اجرا، نمایش و نمایشگاه در یک فهرست', - 'جزئیات میزبان، زمان و ظرفیت پیش از رزرو', - 'راهنمای انتخاب تجربه هنری متناسب با سطح و سلیقه', - ], - faqs: [ - { - question: 'رویداد هنر و فرهنگ برای مبتدی هم مناسب است؟', - answer: - 'بسیاری از برنامه‌ها بدون پیش‌نیاز مهارتی برگزار می‌شوند. توضیحات هر رویداد سطح مخاطب را مشخص می‌کند؛ اگر مبهم بود پیش از پرداخت از میزبان بپرسید.', - }, - { - question: 'تفاوت تماشا و کارگاه هنری چیست؟', - answer: - 'در اجرا و نمایش معمولاً تماشاگر هستید. در کارگاه یا تجربه ساخت، خودتان کار می‌کنید و اغلب مواد یا ابزار لازم در صفحه رویداد آمده است.', - }, - { - question: 'اگر برنامه فعالی در این دسته نبود چه کنم؟', - answer: - 'صفحه را دوباره سر بزنید یا دسته‌های نزدیک مثل یادگیری و تجربه را ببینید. برنامه‌های تازه پس از انتشار میزبان اینجا ظاهر می‌شوند.', - }, - ], - }, - 'learning-experience': { - intro: - 'کارگاه‌ها، کلاس‌ها و تجربه‌های مهارتی حضوری اینجاست؛ از ساختن و پخت تا یادگیری یک تکنیک تازه. پیش از رزرو خروجی جلسه، سطح، نسبت تمرین به توضیح و وسایل لازم را بخوانید.', - highlights: ['خروجی مشخص هر کارگاه را پیش از خرید بسنجید', 'سطح، پیش‌نیاز و ظرفیت در صفحه رویداد', 'رزرو مستقیم با سیاست لغو شفاف'], - faqs: [ - { - question: 'چطور بفهمم کارگاه با سطح من می‌خواند؟', - answer: - 'پیش‌نیاز و مخاطب اعلام‌شده را با تجربه فعلی خود مقایسه کنید. اگر فقط عنوان «از صفر تا صد» آمده و برنامه تمرین روشن نیست، از میزبان نمونه جلسه را بخواهید.', - }, - { - question: 'وسایل کارگاه جداگانه حساب می‌شود؟', - answer: - 'بستگی به میزبان دارد. صفحه رویداد باید بگوید مواد و ابزار در هزینه هست یا باید همراه بیاورید؛ اگر نوشته نشده پیش از پرداخت بپرسید.', - }, - { - question: 'بعد از کارگاه چطور ادامه بدهم؟', - answer: - 'یک تمرین کوچک را تا ۲۴ ساعت بعد تکرار کنید. اگر میزبان فایل یا راه پیگیری دارد از آن استفاده کنید؛ یادگیری ماندگار از اجرای بعد از جلسه می‌آید.', - }, - ], - }, - 'games-entertainment': { - intro: - 'بازی، رقابت و دورهمی‌های سرگرم‌کننده گروهی در این دسته جمع شده‌اند. روحیه جمع، سنگینی قوانین و میزان رقابت را با توضیحات میزبان بسنجید تا شب برای همه لذت‌بخش بماند.', - highlights: [ - 'رومیزی، رقابت تیمی و دورهمی شاد در یک جا', - 'ظرفیت میز و نیاز به دانستن قوانین از قبل', - 'فضای مناسب تازه‌وارد و گروه‌های قدیمی', - ], - faqs: [ - { - question: 'اگر قوانین بازی را بلد نباشم می‌توانم بیایم؟', - answer: 'بسیاری از برنامه‌ها آموزش در محل دارند. صفحه رویداد را بخوانید؛ اگر مسابقه رسمی است معمولاً آشنایی قبلی لازم است.', - }, - { - question: 'برای گروهی که تازه با هم آشنا شده‌اند چه برنامه‌ای بهتر است؟', - answer: 'بازی با قوانین سبک و رقابت ملایم معمولاً بهتر از مسابقه سنگین است. ظرفیت متوسط کمک می‌کند کسی در حاشیه نماند.', - }, - { - question: 'رزرو گروهی چطور انجام می‌شود؟', - answer: - 'هر نفر از صفحه همان رویداد رزرو می‌کند مگر میزبان بلیط گروهی جداگانه تعریف کرده باشد. زمان را اول با همه هماهنگ کنید، بعد بلیط بخرید.', - }, - ], - }, - 'sports-adventure': { - intro: - 'ورزش، طبیعت، سفر کوتاه و فعالیت بدنی بیرون از فضای معمول در این صفحه دیده می‌شود. مسافت، سطح سختی، وسایل ضروری و نقطه شروع را پیش از ثبت‌نام با توان واقعی خودتان بسنجید.', - highlights: ['سطح مسیر و مدت حرکت در توضیحات رویداد', 'فهرست وسایل ضروری و نقطه شروع دقیق', 'برنامه جایگزین در هوای نامساعد'], - faqs: [ - { - question: 'برای اولین برنامه طبیعت‌گردی چه سطحی انتخاب کنم؟', - answer: 'مسیر کوتاه‌تر، نزدیک شهر و با توقف مشخص. عنوان «سبک» کافی نیست؛ مسافت و مدت را بخوانید و اگر مبهم بود از میزبان بپرسید.', - }, - { - question: 'چه وسایلی معمولاً لازم است؟', - answer: - 'کفش مناسب، آب و پوشش متناسب با هوا حداقل‌اند. هر میزبان فهرست دقیق را در صفحه رویداد می‌نویسد؛ برنامه غروب ممکن است نور هم بخواهد.', - }, - { - question: 'اگر وسط مسیر خسته شوم چه کنم؟', - answer: 'زود به راهنما بگویید. پنهان کردن خستگی خطر را بیشتر می‌کند. پیش از رزرو بپرسید نقطه بازگشت میانی وجود دارد یا نه.', - }, - ], - }, - 'food-gatherings': { - intro: - 'غذا، قهوه، شام، پیک‌نیک و دورهمی‌هایی که حول میز شکل می‌گیرند اینجاست. منو، موارد شامل قیمت، محدودیت غذایی و شیوه نشستن غریبه‌ها را پیش از رزرو بخوانید.', - highlights: ['شفافیت منو و موارد داخل قیمت', 'ظرفیت میز و ریتم معاشرت', 'امکان اعلام رژیم و حساسیت غذایی'], - faqs: [ - { - question: 'غذا در قیمت بلیط هست؟', - answer: 'در بعضی برنامه‌ها بله و در بعضی جداگانه. صفحه رویداد باید این را روشن کند؛ اگر نشده پیش از پرداخت از میزبان بپرسید.', - }, - { - question: 'با رژیم گیاهی یا حساسیت غذایی می‌توانم شرکت کنم؟', - answer: 'اغلب میزبان‌ها اگر زود بگویید گزینه جایگزین می‌چینند. روز اجرا دیر است؛ همان موقع رزرو محدودیت را بنویسید یا بپرسید.', - }, - { - question: 'این دورهمی‌ها برای آدم تنها مناسب‌اند؟', - answer: 'بله؛ بسیاری از میزها برای آشنایی چیده شده‌اند. ظرفیت کمتر و معرفی کوتاه معمولاً ورود را آسان‌تر می‌کند.', - }, - ], - }, - 'conversation-connection': { - intro: - 'آشنایی، شبکه‌سازی، نشست موضوعی و جمع‌های تخصصی در این دسته است. موضوع جلسه، وجود تسهیل‌گر و ظرفیت را بخوانید تا فضایی انتخاب کنید که حرف زدن در آن محترمانه و ممکن باشد.', - highlights: ['نشست موضوعی در کنار دورهمی آشنایی', 'قوانین احترام و ظرفیت گروه', 'ورود آسان برای کسی که بار اول می‌آید'], - faqs: [ - { - question: 'اگر خجالتی باشم این جمع‌ها مناسب‌اند؟', - answer: - 'برنامه‌هایی با معرفی دو نفره یا سؤال مشخص روی میز ورود را آسان‌تر از میکروفن باز می‌کنند. لازم نیست اولین نفر حرف بزنید.', - }, - { - question: 'تفاوت نشست تخصصی و دورهمی آشنایی چیست؟', - answer: - 'نشست تخصصی معمولاً موضوع و گاهی پیش‌زمینه می‌خواهد. دورهمی آشنایی روی شناخت افراد تازه‌وارد تمرکز دارد. توضیحات میزبان این را مشخص می‌کند.', - }, - { - question: 'آیا می‌توانم بعد از جلسه با کسی در ارتباط بمانم؟', - answer: - 'بله؛ یک پیام کوتاه با ارجاع به موضوع همان جلسه معمولاً بهتر از رد و بدل کردن شماره بدون زمینه است. اجبار به تبادل تماس در خود رویداد نیست.', - }, - ], - }, - }, - priorityLandings: { - 'arts-culture': { - tehran: { - title: 'رویدادهای هنر و فرهنگ تهران', - description: 'اجرا، موسیقی، نمایش و نمایشگاه‌های حضوری تهران را در قبیله ببینید و برنامه مناسب را رزرو کنید.', - intro: - 'تهران میزبان اجراهای کوچک، نمایشگاه و تجربه‌های هنری جمعی است. در این صفحه برنامه‌های هنر و فرهنگ را بر اساس سبک، زمان و محل برگزاری مقایسه کنید.', - highlights: ['اجرا و نمایش حضوری در محله‌های مختلف تهران', 'جزئیات میزبان، ظرفیت و مدت برنامه', 'رزرو مستقیم از صفحه همان رویداد'], - faqs: [ - { - question: 'رویداد هنری تهران معمولاً کجا برگزار می‌شود؟', - answer: 'بسته به میزبان، سالن کوچک، گالری، کافه یا فضای کارگاهی است. نشانی دقیق در صفحه همان رویداد آمده است.', - }, - { - question: 'برای شرکت در اجرا باید پیش‌زمینه هنری داشته باشم؟', - answer: 'بیشتر برنامه‌های تماشایی پیش‌نیاز ندارند. کارگاه ساخت ممکن است مواد یا سطح اعلام کند؛ توضیحات رویداد را بخوانید.', - }, - ], - }, - }, - 'learning-experience': { - tehran: { - title: 'کارگاه‌ها و تجربه‌های یادگیری تهران', - description: 'کلاس‌ها و کارگاه‌های مهارتی حضوری تهران را مقایسه کنید و بر اساس سطح، خروجی و زمان رزرو کنید.', - intro: - 'اگر دنبال ساختن یا یاد گرفتن یک مهارت تازه در تهران هستید، این فهرست کارگاه‌ها و کلاس‌های فعال را کنار هم می‌گذارد تا سطح، مدت و خروجی را پیش از پرداخت بسنجید.', - highlights: ['کارگاه عملی با خروجی مشخص', 'اطلاعات سطح، وسایل و ظرفیت', 'امکان پرسش از میزبان پیش از رزرو'], - faqs: [ - { - question: 'کارگاه‌های تهران برای مبتدی مناسب‌اند؟', - answer: 'بسیاری با سطح مقدماتی برگزار می‌شوند. پیش‌نیاز را در صفحه رویداد بخوانید و اگر مبهم بود از میزبان بپرسید.', - }, - { - question: 'وسایل را باید خودم ببرم؟', - answer: 'بستگی به کارگاه دارد. موارد شامل قیمت و وسایل همراه در توضیحات آمده؛ اگر نیامده پیش از خرید سؤال کنید.', - }, - ], - }, - }, - 'games-entertainment': { - tehran: { - title: 'رویدادهای بازی و سرگرمی تهران', - description: 'شب‌های بازی، رقابت و دورهمی‌های سرگرم‌کننده تهران را ببینید و برنامه متناسب با گروه خود رزرو کنید.', - intro: - 'از رومیزی تا دورهمی شاد، برنامه‌های بازی تهران اینجا جمع می‌شوند. قوانین، ظرفیت میز و میزان رقابت را با روحیه گروه خودتان بسنجید.', - highlights: ['رومیزی و دورهمی در کافه و فضای اختصاصی', 'آموزش در محل برای تازه‌واردها', 'زمان، ظرفیت و هزینه روی کارت رویداد'], - faqs: [ - { - question: 'اگر بازی را بلد نباشم می‌توانم در تهران شرکت کنم؟', - answer: 'در بیشتر دورهمی‌ها قوانین همان شب گفته می‌شود. مسابقه‌های رسمی را از توضیحات رویداد تشخیص دهید.', - }, - { - question: 'برای گروه چند نفره چطور رزرو کنیم؟', - answer: 'زمان را با همه هماهنگ کنید و هر نفر از صفحه رویداد رزرو کند، مگر میزبان بلیط گروهی جداگانه گذاشته باشد.', - }, - ], - }, - }, - 'sports-adventure': { - tehran: { - title: 'ورزش و ماجراجویی گروهی تهران', - description: 'پیاده‌روی، طبیعت‌گردی و فعالیت بدنی جمعی اطراف تهران را با سطح مسیر و وسایل لازم مقایسه و رزرو کنید.', - intro: - 'برنامه‌های ورزشی و ماجراجویانه تهران از حرکت داخل شهر تا مسیرهای اطراف را پوشش می‌دهند. مسافت، مدت و نقطه شروع را پیش از ثبت‌نام با توان خود بسنجید.', - highlights: ['سطح مسیر و مدت حرکت', 'فهرست وسایل و ساعت حرکت', 'شرایط آب‌وهوا و برنامه جایگزین'], - faqs: [ - { - question: 'برای اولین برنامه طبیعت اطراف تهران چه سطحی مناسب است؟', - answer: 'مسیر کوتاه‌تر با توقف مشخص. فقط به کلمه «سبک» بسنده نکنید؛ مسافت و مدت را در صفحه رویداد بخوانید.', - }, - { - question: 'نقطه شروع برنامه‌ها کجاست؟', - answer: 'هر میزبان نقطه و ساعت حرکت را در رویداد می‌نویسد. رسیدن بعد از حرکت گروه معمولاً به معنای از دست دادن برنامه است.', - }, - ], - }, - }, - 'food-gatherings': { - tehran: { - title: 'خوراک و دورهمی‌های تهران', - description: 'شام، قهوه، پیک‌نیک و دورهمی‌های حول میز در تهران را ببینید و با منو و ظرفیت مناسب رزرو کنید.', - intro: - 'اگر دنبال نشستن دور یک میز در تهران هستید — از کافه گفت‌وگو تا شام گروهی — این صفحه برنامه‌ها را از نظر منو، قیمت و شیوه معاشرت کنار هم می‌گذارد.', - highlights: ['منو و موارد داخل قیمت', 'ظرفیت میز و ریتم آشنایی', 'امکان اعلام رژیم غذایی پیش از رزرو'], - faqs: [ - { - question: 'غذا در بلیط دورهمی‌های تهران هست؟', - answer: 'در بعضی برنامه‌ها بله. صفحه رویداد باید غذا، نوشیدنی و سرویس را جدا کند؛ اگر نکرده از میزبان بپرسید.', - }, - { - question: 'تنهایی هم می‌توانم بیایم؟', - answer: 'بله. میزهای آشنایی معمولاً برای نفرات تکی چیده می‌شوند. ظرفیت کمتر ورود را آسان‌تر می‌کند.', - }, - ], - }, - }, - 'conversation-connection': { - tehran: { - title: 'گفت‌وگو و دورهمی‌های آشنایی تهران', - description: 'نشست‌های موضوعی، شبکه‌سازی و جمع‌های تخصصی تهران را مقایسه کنید و به دورهمی مناسب بپیوندید.', - intro: - 'تهران جمع‌های گفت‌وگو با موضوع‌های مختلف دارد؛ از آشنایی تا نشست تخصصی. تسهیل‌گر، ظرفیت و قوانین احترام را بخوانید تا فضایی انتخاب کنید که حرف زدن در آن راحت باشد.', - highlights: ['نشست موضوعی و دورهمی آشنایی', 'ظرفیت گروه و وجود تسهیل‌گر', 'قوانین احترام و ورود تازه‌وارد'], - faqs: [ - { - question: 'اگر در جمع غریبه خجالت می‌کشم چه برنامه‌ای بهتر است؟', - answer: 'جلسه‌هایی با معرفی دو نفره یا سؤال مشخص روی میز معمولاً از میکروفن باز آسان‌ترند. لازم نیست اول شما حرف بزنید.', - }, - { - question: 'نشست تخصصی با دورهمی آشنایی چه فرقی دارد؟', - answer: - 'نشست تخصصی موضوع و گاهی پیش‌زمینه می‌خواهد. دورهمی آشنایی روی شناخت افراد تازه تمرکز دارد. توضیحات میزبان این را روشن می‌کند.', - }, - ], - }, - }, - }, } as const diff --git a/texts/status.ts b/texts/status.ts index 678aaca..7049490 100644 --- a/texts/status.ts +++ b/texts/status.ts @@ -26,7 +26,6 @@ export const status = { published, draft, pending, - userRole: { user, admin, diff --git a/texts/support.ts b/texts/support.ts index 4b34a7b..83ac17b 100644 --- a/texts/support.ts +++ b/texts/support.ts @@ -1,36 +1,13 @@ -import { common } from './common' - const open = 'باز' const closed = 'بسته' export const support = { - title: 'پشتیبانی', - newRequest: 'درخواست جدید', - subjectLabel: 'موضوع', - priorityLabel: 'اولویت', - loadingList: 'در حال دریافت درخواست‌ها…', - emptyTitle: 'هنوز درخواستی ثبت نکرده‌اید', - emptyDescription: 'سؤال یا مشکلتان را برای تیم پشتیبانی بنویسید.', - listLoadFailed: 'دریافت تیکت‌ها ناموفق بود', - formIncomplete: 'موضوع و توضیحات را کامل کنید', - createSuccess: 'درخواست شما ثبت شد', - createFailed: 'ثبت درخواست ناموفق بود', - detailFallbackTitle: 'جزئیات درخواست', - detailLoadFailed: 'دریافت تیکت ناموفق بود', - loadingDetail: 'در حال دریافت…', - statusLabel: 'وضعیت', - adminSender: 'پشتیبانی قبیله', - youSender: 'شما', - replyPlaceholder: 'پاسخ شما…', - sendReply: 'ارسال پاسخ', - replyFailed: 'ارسال پاسخ ناموفق بود', - closedNotice: 'این درخواست بسته شده است.', categories: { - general: common.general, - account: common.account, + general: 'عمومی', + account: 'حساب کاربری', event: 'رویداد', booking: 'رزرو', - payment: common.payment, + payment: 'پرداخت', technical: 'مشکل فنی', }, priorities: { diff --git a/texts/validation.ts b/texts/validation.ts index 5b5cf9f..58ca95e 100644 --- a/texts/validation.ts +++ b/texts/validation.ts @@ -18,15 +18,6 @@ export const validation = { otpDigits: 'کد باید ۴ رقم باشد', birthDateRange: 'تاریخ تولد باید بین سال‌های ۱۳۳۰ تا ۱۳۹۵ باشد', }, - profile: { - bioMax: 'بیوگرافی حداکثر ۲۰۰۰ کاراکتر است', - addressMax: 'آدرس حداکثر ۵۰۰ کاراکتر است', - }, - identity: { - contractRequired: 'پذیرش قرارداد همکاری الزامی است', - otpRequired: 'کد تأیید را وارد کنید', - otpDigits: 'کد تأیید باید ۴ رقم باشد', - }, withdrawal: { accountHolderRequired: 'نام صاحب حساب را وارد کنید', accountHolderMax: 'نام صاحب حساب حداکثر ۲۰۰ کاراکتر است', @@ -38,36 +29,8 @@ export const validation = { amountPositive: 'مبلغ باید بیشتر از صفر باشد', }, eventWizard: { - titleRequired: 'عنوان رویداد الزامی است', - titleMax: 'عنوان حداکثر ۲۰۰ کاراکتر است', - slugRequired: 'اسلاگ الزامی است', - slugMax: 'اسلاگ حداکثر ۲۲۰ کاراکتر است', - slugPattern: 'اسلاگ فقط می‌تواند شامل حروف کوچک انگلیسی، عدد و خط تیره باشد', - slugReserved: 'این اسلاگ برای مسیرهای سیستمی رزرو شده است', - categoryRequired: 'دسته‌بندی را انتخاب کنید', - shortDescriptionMax: 'توضیح کوتاه حداکثر ۵۰۰ کاراکتر است', - startDateRequired: 'تاریخ شروع الزامی است', - startTimeRequired: 'ساعت شروع الزامی است', - endDateRequired: 'تاریخ پایان الزامی است', - endTimeRequired: 'ساعت پایان الزامی است', - addressRequired: 'آدرس الزامی است', - startEndRequired: 'تاریخ و ساعت شروع و پایان الزامی است', endAfterStart: 'زمان پایان باید بعد از زمان شروع باشد', - generalAreaRequired: 'برای نمایش «فقط برای رزروکنندگان»، منطقه کلی را وارد کنید', - priceRequired: 'قیمت را وارد کنید', - priceNonNegative: 'قیمت نمی‌تواند منفی باشد', - capacityRequired: 'ظرفیت را وارد کنید', - capacityInteger: 'ظرفیت باید عدد صحیح باشد', - capacityMin: 'ظرفیت باید حداقل ۱ باشد', - reservedCapacityRequired: 'ظرفیت رزرو شده را وارد کنید', - reservedCapacityInteger: 'ظرفیت رزرو شده باید عدد صحیح باشد', - reservedCapacityNonNegative: 'ظرفیت رزرو شده نمی‌تواند منفی باشد', - cancelPenaltyRequired: 'درصد جریمه را وارد کنید', - cancelPenaltyNonNegative: 'درصد جریمه نمی‌تواند منفی باشد', - cancelPenaltyMax: 'درصد جریمه حداکثر ۱۰۰ است', cancelPenaltyOrder: 'درصد جریمه باید با نزدیک‌شدن به زمان رویداد بیشتر یا مساوی شود', - paidPricePositive: 'برای رویداد پولی، قیمت باید بیشتر از صفر باشد', - reservedExceedsCapacity: 'ظرفیت رزرو شده نمی‌تواند از ظرفیت کل بیشتر باشد', faqQuestionRequired: 'سوال را وارد کنید', faqAnswerRequired: 'پاسخ را وارد کنید', }, diff --git a/texts/wallet.ts b/texts/wallet.ts deleted file mode 100644 index e798eea..0000000 --- a/texts/wallet.ts +++ /dev/null @@ -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 diff --git a/validation/consumerEventWizardStep2.ts b/validation/consumerEventWizardStep2.ts deleted file mode 100644 index 7a86782..0000000 --- a/validation/consumerEventWizardStep2.ts +++ /dev/null @@ -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> - -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 } -} diff --git a/validation/eventWizard.test.ts b/validation/eventWizard.test.ts index 2a82de0..4d97b70 100644 --- a/validation/eventWizard.test.ts +++ b/validation/eventWizard.test.ts @@ -1,227 +1,9 @@ import { describe, expect, it } from 'vitest' -import { - ConsumerEventWizardStep2Validation, - EventWizardFaqFormValidation, - EventWizardStep1Validation, - EventWizardStep2Validation, - EventWizardStep3Validation, -} from '@/validation/eventWizard' +import { EventWizardFaqFormValidation } from '@/validation/eventWizard' -describe('event wizard validation', () => { - it('requires title, slug, and category on step 1', () => { - expect(EventWizardStep1Validation.safeParse({ title: '', slug: '', categoryId: '' }).success).toBe(false) - expect( - EventWizardStep1Validation.parse({ - title: 'رویداد تست', - slug: 'test-event', - categoryId: '1', - shortDescription: '', - description: '', - }) - ).toMatchObject({ title: 'رویداد تست', slug: 'test-event', categoryId: '1' }) - }) - - it('rejects invalid slug patterns', () => { - expect( - EventWizardStep1Validation.safeParse({ - title: 'عنوان', - slug: 'Bad_Slug', - categoryId: '1', - }).success - ).toBe(false) - }) - - it.each(['new', '462f10ff-7466-4a58-9631-d09f02b76eaf'])('rejects the reserved event slug %s', (slug) => { - expect( - EventWizardStep1Validation.safeParse({ - title: 'عنوان', - slug, - categoryId: '1', - }).success - ).toBe(false) - }) - - it('requires schedule and location on step 2 and enforces end after start', () => { - const base = { - startDate: '2026-07-19T00:00:00.000Z', - startTime: 600, - endDate: '2026-07-19T00:00:00.000Z', - endTime: 720, - cityId: '10', - address: 'تهران', - addressVisibility: 'public' as const, - } - - expect(EventWizardStep2Validation.parse(base)).toMatchObject({ cityId: '10', address: 'تهران' }) - expect(EventWizardStep2Validation.safeParse({ ...base, endTime: 600 }).success).toBe(false) - expect(EventWizardStep2Validation.safeParse({ ...base, cityId: '' }).success).toBe(false) - }) - - it('validates consumer step 2 without city selection (city comes from host profile)', () => { - const base = { - startDate: '2026-07-19T00:00:00.000Z', - startTime: 600, - endDate: '2026-07-19T00:00:00.000Z', - endTime: 720, - address: 'تهران', - addressVisibility: 'public' as const, - } - - expect(ConsumerEventWizardStep2Validation.parse(base)).toMatchObject({ address: 'تهران' }) - expect(ConsumerEventWizardStep2Validation.safeParse({ ...base, endTime: 600 }).success).toBe(false) - expect(ConsumerEventWizardStep2Validation.safeParse({ ...base, address: '' }).success).toBe(false) - }) - - it('requires generalArea when addressVisibility is attendees_only', () => { - const base = { - startDate: '2026-07-19T00:00:00.000Z', - startTime: 600, - endDate: '2026-07-19T00:00:00.000Z', - endTime: 720, - cityId: '10', - address: 'تهران', - } - - expect(EventWizardStep2Validation.safeParse({ ...base, addressVisibility: 'attendees_only', generalArea: '' }).success).toBe(false) - expect( - EventWizardStep2Validation.safeParse({ - ...base, - addressVisibility: 'attendees_only', - generalArea: 'مشهد، خیابان هفت‌تیر', - }).success - ).toBe(true) - - const consumerBase = { - startDate: '2026-07-19T00:00:00.000Z', - startTime: 600, - endDate: '2026-07-19T00:00:00.000Z', - endTime: 720, - address: 'تهران', - } - - expect( - ConsumerEventWizardStep2Validation.safeParse({ - ...consumerBase, - addressVisibility: 'attendees_only', - generalArea: '', - }).success - ).toBe(false) - }) - - it('requires positive price for paid events and capacity of at least 1', () => { - expect( - EventWizardStep3Validation.parse({ - isFree: true, - price: 0, - capacity: 20, - reservedCapacity: 0, - genderRestriction: 'open', - ageRestriction: 'open', - cancellationFeePercent: 0, - cancellationFeePercent12To24Hours: 0, - cancellationFeePercentMoreThan24Hours: 0, - isDiscoverable: true, - autoCreateGroup: true, - waitlistAutoOffer: true, - sendReviewRequestSms: false, - }) - ).toMatchObject({ isFree: true, price: 0, capacity: 20 }) - - expect( - EventWizardStep3Validation.safeParse({ - isFree: false, - price: 0, - capacity: 20, - reservedCapacity: 0, - genderRestriction: 'open', - ageRestriction: 'open', - cancellationFeePercent: 0, - cancellationFeePercent12To24Hours: 0, - cancellationFeePercentMoreThan24Hours: 0, - isDiscoverable: true, - autoCreateGroup: true, - waitlistAutoOffer: true, - sendReviewRequestSms: false, - }).success - ).toBe(false) - - expect( - EventWizardStep3Validation.safeParse({ - isFree: false, - price: 100000, - capacity: 0, - reservedCapacity: 0, - genderRestriction: 'open', - ageRestriction: 'open', - cancellationFeePercent: 0, - cancellationFeePercent12To24Hours: 0, - cancellationFeePercentMoreThan24Hours: 0, - isDiscoverable: true, - autoCreateGroup: true, - waitlistAutoOffer: true, - sendReviewRequestSms: false, - }).success - ).toBe(false) - }) - - it('rejects unknown gender and age restriction values', () => { - const base = { - isFree: true, - price: 0, - capacity: 20, - reservedCapacity: 0, - genderRestriction: 'open', - ageRestriction: 'open', - cancellationFeePercent: 0, - cancellationFeePercent12To24Hours: 0, - cancellationFeePercentMoreThan24Hours: 0, - isDiscoverable: true, - autoCreateGroup: true, - waitlistAutoOffer: true, - sendReviewRequestSms: false, - } - - expect(EventWizardStep3Validation.safeParse({ ...base, genderRestriction: 'women' }).success).toBe(false) - expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_20' }).success).toBe(false) - expect(EventWizardStep3Validation.safeParse({ ...base, ageRestriction: 'age_15_19' }).success).toBe(true) - }) - - it('requires cancellation penalties to decrease as cancellation gets earlier', () => { - const base = { - isFree: false, - price: 100_000, - capacity: 20, - reservedCapacity: 0, - genderRestriction: 'open', - ageRestriction: 'open', - cancellationFeePercent: 30, - cancellationFeePercent12To24Hours: 20, - cancellationFeePercentMoreThan24Hours: 10, - isDiscoverable: true, - autoCreateGroup: true, - waitlistAutoOffer: true, - sendReviewRequestSms: false, - } - - expect(EventWizardStep3Validation.safeParse(base).success).toBe(true) - expect( - EventWizardStep3Validation.safeParse({ - ...base, - cancellationFeePercentMoreThan24Hours: 25, - }).success - ).toBe(false) - expect( - EventWizardStep3Validation.safeParse({ - ...base, - cancellationFeePercent: 0, - cancellationFeePercent12To24Hours: 0, - cancellationFeePercentMoreThan24Hours: 0, - }).success - ).toBe(true) - }) - - it('requires both question and answer for FAQ rows', () => { +describe('EventWizardFaqFormValidation', () => { + it('requires question and answer', () => { expect(EventWizardFaqFormValidation.safeParse({ question: '', answer: 'پاسخ' }).success).toBe(false) expect(EventWizardFaqFormValidation.parse({ question: 'سوال؟', answer: 'پاسخ' })).toEqual({ question: 'سوال؟', diff --git a/validation/eventWizard.ts b/validation/eventWizard.ts index 19ecd58..7e457a9 100644 --- a/validation/eventWizard.ts +++ b/validation/eventWizard.ts @@ -1,155 +1,9 @@ import { z } from 'zod' -import { EVENT_SLUG_MAX_LENGTH } from '@/features/events/eventSlug' -import { isPublicEventSlug } from '@/lib/routing/eventRoute' import { texts } from '@/texts' -// هم‌تراز با اسلاگ دسته‌بندی‌ها — فقط لاتین، بدون تبدیل ارقام فارسی -const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ const v = texts.validation.eventWizard -/** ترکیب تاریخ ISO و دقیقهٔ روز برای مقایسهٔ شروع/پایان (بدون وابستگی به components) */ -function toDateTime(dateIso: string, minutes: number): Date | null { - const date = new Date(dateIso) - - if (!Number.isFinite(date.getTime()) || !Number.isFinite(minutes)) return null - - date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0) - - return date -} - -export const EventWizardStep1Validation = z.object({ - title: z.string().trim().min(1, v.titleRequired).max(200, v.titleMax), - slug: z - .string() - .trim() - .min(1, v.slugRequired) - .max(EVENT_SLUG_MAX_LENGTH, v.slugMax) - .regex(SLUG_PATTERN, v.slugPattern) - .refine(isPublicEventSlug, v.slugReserved), - categoryId: z.string().min(1, v.categoryRequired), - shortDescription: z.string().trim().max(500, v.shortDescriptionMax).optional().or(z.literal('')), - description: z.string().optional().or(z.literal('')), -}) - -export type EventWizardStep1Values = z.infer - -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 - -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 - export const EventWizardFaqFormValidation = z.object({ question: z.string().trim().min(1, v.faqQuestionRequired), answer: z.string().trim().min(1, v.faqAnswerRequired),