diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md index db5dee3..5547e30 100644 --- a/.github/BRANCH_PROTECTION.md +++ b/.github/BRANCH_PROTECTION.md @@ -33,5 +33,5 @@ Add these repository secrets (same values as the monorepo / VPS `backend/.env`): - `TELEGRAM_GROUP_THREAD_ID` (optional forum topic) - `TELEGRAM_CHAT_ID` (fallback private chat) -Successful/failed deploys send a **frontend-specific** message via -`scripts/notify-deploy.sh`. +Successful/failed deploys send an **admin/backoffice-specific** message via +`scripts/notify-deploy.sh` (distinct from the consumer frontend notify copy). diff --git a/app/(dashboard)/admin/events/[id]/layout.tsx b/app/(dashboard)/admin/events/[id]/layout.tsx new file mode 100644 index 0000000..c42e2ec --- /dev/null +++ b/app/(dashboard)/admin/events/[id]/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.manageEventDetail, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/audit-logs/layout.tsx b/app/(dashboard)/audit-logs/layout.tsx new file mode 100644 index 0000000..655bdad --- /dev/null +++ b/app/(dashboard)/audit-logs/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.auditLogs, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/bank-accounts/layout.tsx b/app/(dashboard)/bank-accounts/layout.tsx new file mode 100644 index 0000000..cfad513 --- /dev/null +++ b/app/(dashboard)/bank-accounts/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.bankAccounts, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/blog-articles/layout.tsx b/app/(dashboard)/blog-articles/layout.tsx new file mode 100644 index 0000000..a703e7e --- /dev/null +++ b/app/(dashboard)/blog-articles/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.blogArticles, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/bookings/layout.tsx b/app/(dashboard)/bookings/layout.tsx new file mode 100644 index 0000000..21ff1ea --- /dev/null +++ b/app/(dashboard)/bookings/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.bookings, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/chat-oversight/[id]/layout.tsx b/app/(dashboard)/chat-oversight/[id]/layout.tsx new file mode 100644 index 0000000..27692ac --- /dev/null +++ b/app/(dashboard)/chat-oversight/[id]/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.chatOversightDetail, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/chat-oversight/layout.tsx b/app/(dashboard)/chat-oversight/layout.tsx new file mode 100644 index 0000000..d8f10f0 --- /dev/null +++ b/app/(dashboard)/chat-oversight/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.chatOversight, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/cities/layout.tsx b/app/(dashboard)/cities/layout.tsx new file mode 100644 index 0000000..61dc94a --- /dev/null +++ b/app/(dashboard)/cities/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.cities, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/contact-messages/layout.tsx b/app/(dashboard)/contact-messages/layout.tsx new file mode 100644 index 0000000..c982991 --- /dev/null +++ b/app/(dashboard)/contact-messages/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.contactMessages, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/dashboard/layout.tsx b/app/(dashboard)/dashboard/layout.tsx new file mode 100644 index 0000000..4e6d6df --- /dev/null +++ b/app/(dashboard)/dashboard/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.dashboard, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/discount-codes/layout.tsx b/app/(dashboard)/discount-codes/layout.tsx new file mode 100644 index 0000000..f30af39 --- /dev/null +++ b/app/(dashboard)/discount-codes/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.discountCodes, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/event-categories/layout.tsx b/app/(dashboard)/event-categories/layout.tsx new file mode 100644 index 0000000..a6d4368 --- /dev/null +++ b/app/(dashboard)/event-categories/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.eventCategories, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/events/layout.tsx b/app/(dashboard)/events/layout.tsx new file mode 100644 index 0000000..f46f924 --- /dev/null +++ b/app/(dashboard)/events/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.manageEvents, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/guest-lists/layout.tsx b/app/(dashboard)/guest-lists/layout.tsx new file mode 100644 index 0000000..42bc021 --- /dev/null +++ b/app/(dashboard)/guest-lists/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.guestLists, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/identity-verifications/layout.tsx b/app/(dashboard)/identity-verifications/layout.tsx new file mode 100644 index 0000000..1f18b22 --- /dev/null +++ b/app/(dashboard)/identity-verifications/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.identityVerifications, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/manage-events/[id]/edit/layout.tsx b/app/(dashboard)/manage-events/[id]/edit/layout.tsx new file mode 100644 index 0000000..450f336 --- /dev/null +++ b/app/(dashboard)/manage-events/[id]/edit/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.manageEventEdit, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/manage-events/[id]/layout.tsx b/app/(dashboard)/manage-events/[id]/layout.tsx new file mode 100644 index 0000000..c42e2ec --- /dev/null +++ b/app/(dashboard)/manage-events/[id]/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.manageEventDetail, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/manage-events/[id]/page.tsx b/app/(dashboard)/manage-events/[id]/page.tsx index ccef697..acbadba 100644 --- a/app/(dashboard)/manage-events/[id]/page.tsx +++ b/app/(dashboard)/manage-events/[id]/page.tsx @@ -1,9 +1,10 @@ import type { Metadata } from 'next' import AdminEventDetail from '@/features/events/detail/AdminEventDetail' +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' export const metadata: Metadata = { - robots: { index: false, follow: false }, + title: ADMIN_PAGE_TITLES.manageEventDetail, } const AdminEventDetailPage = () => diff --git a/app/(dashboard)/manage-events/layout.tsx b/app/(dashboard)/manage-events/layout.tsx new file mode 100644 index 0000000..f46f924 --- /dev/null +++ b/app/(dashboard)/manage-events/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.manageEvents, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/manage-events/new/layout.tsx b/app/(dashboard)/manage-events/new/layout.tsx new file mode 100644 index 0000000..16ea666 --- /dev/null +++ b/app/(dashboard)/manage-events/new/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.manageEventNew, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/manual-notifications/layout.tsx b/app/(dashboard)/manual-notifications/layout.tsx new file mode 100644 index 0000000..e0e4c36 --- /dev/null +++ b/app/(dashboard)/manual-notifications/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.notifications, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/notification-rules/layout.tsx b/app/(dashboard)/notification-rules/layout.tsx new file mode 100644 index 0000000..e0e4c36 --- /dev/null +++ b/app/(dashboard)/notification-rules/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.notifications, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/notifications/layout.tsx b/app/(dashboard)/notifications/layout.tsx new file mode 100644 index 0000000..e0e4c36 --- /dev/null +++ b/app/(dashboard)/notifications/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.notifications, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/payments/layout.tsx b/app/(dashboard)/payments/layout.tsx new file mode 100644 index 0000000..d68a663 --- /dev/null +++ b/app/(dashboard)/payments/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.payments, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/provinces/layout.tsx b/app/(dashboard)/provinces/layout.tsx new file mode 100644 index 0000000..8b6ec29 --- /dev/null +++ b/app/(dashboard)/provinces/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.provinces, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/push-deliveries/layout.tsx b/app/(dashboard)/push-deliveries/layout.tsx new file mode 100644 index 0000000..e0e4c36 --- /dev/null +++ b/app/(dashboard)/push-deliveries/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.notifications, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/reviews/layout.tsx b/app/(dashboard)/reviews/layout.tsx new file mode 100644 index 0000000..648e8c7 --- /dev/null +++ b/app/(dashboard)/reviews/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.reviews, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/settlements/layout.tsx b/app/(dashboard)/settlements/layout.tsx new file mode 100644 index 0000000..cf05eb0 --- /dev/null +++ b/app/(dashboard)/settlements/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.settlements, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/sms-messages/layout.tsx b/app/(dashboard)/sms-messages/layout.tsx new file mode 100644 index 0000000..e0e4c36 --- /dev/null +++ b/app/(dashboard)/sms-messages/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.notifications, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/support-tickets/[id]/layout.tsx b/app/(dashboard)/support-tickets/[id]/layout.tsx new file mode 100644 index 0000000..9b4cfb4 --- /dev/null +++ b/app/(dashboard)/support-tickets/[id]/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.supportTicketDetail, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/support-tickets/layout.tsx b/app/(dashboard)/support-tickets/layout.tsx new file mode 100644 index 0000000..4d8a21e --- /dev/null +++ b/app/(dashboard)/support-tickets/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.supportTickets, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/user-reports/layout.tsx b/app/(dashboard)/user-reports/layout.tsx new file mode 100644 index 0000000..ede141c --- /dev/null +++ b/app/(dashboard)/user-reports/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.userReports, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/users/[id]/layout.tsx b/app/(dashboard)/users/[id]/layout.tsx new file mode 100644 index 0000000..12ad7bc --- /dev/null +++ b/app/(dashboard)/users/[id]/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.userDetail, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/users/layout.tsx b/app/(dashboard)/users/layout.tsx new file mode 100644 index 0000000..ef8759c --- /dev/null +++ b/app/(dashboard)/users/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.users, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/wallet-deposits/layout.tsx b/app/(dashboard)/wallet-deposits/layout.tsx new file mode 100644 index 0000000..e6ed11a --- /dev/null +++ b/app/(dashboard)/wallet-deposits/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.walletDeposits, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/(dashboard)/withdrawal-requests/layout.tsx b/app/(dashboard)/withdrawal-requests/layout.tsx new file mode 100644 index 0000000..61958c2 --- /dev/null +++ b/app/(dashboard)/withdrawal-requests/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' + +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' + +export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.withdrawalRequests, +} + +export default function Layout({ children }: { children: ReactNode }) { + return children +} diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx index b4617e9..ff93833 100644 --- a/app/auth/layout.tsx +++ b/app/auth/layout.tsx @@ -5,11 +5,13 @@ import Image from 'next/image' import SessionProviders from '@/components/providers/SessionProviders' import { withBasePath } from '@/constants/images' +import { ADMIN_PAGE_TITLES } from '@/constants/adminPageTitles' import { texts } from '@/texts' // Login/signup — already disallowed in robots.ts; noindex is defense in // depth against the URL surfacing blank in search results if ever linked. export const metadata: Metadata = { + title: ADMIN_PAGE_TITLES.auth, robots: { index: false, follow: false }, } @@ -19,7 +21,7 @@ const logoSrc = withBasePath('/logo.svg') const AuthLayout = ({ children }: { children: React.ReactNode }) => { return ( -
+
{ className="absolute inset-0 bg-black/20 backdrop-blur-md" /> -
+
{ @@ -12,7 +11,7 @@ const AdminRoleGuard = ({ children }: { children: React.ReactNode }) => { useEffect(() => { if (user && user.role !== 'admin') { - router.replace(CONSUMER_ROUTES.HOME) + router.replace('/auth') } }, [router, user]) diff --git a/components/events/create/consumer/ConsumerEventCreateWizard.tsx b/components/events/create/consumer/ConsumerEventCreateWizard.tsx deleted file mode 100644 index 1cd12d4..0000000 --- a/components/events/create/consumer/ConsumerEventCreateWizard.tsx +++ /dev/null @@ -1,453 +0,0 @@ -'use client' - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' - -import { texts } from '@/texts' -import type { PreviousAttendee } from '@/services/events' -import { addToast } from '@/lib/toast' -import ConsumerActionButtons from '@/components/consumer/ConsumerActionButtons' -import ConsumerWizardStepper from '@/components/events/create/consumer/ConsumerWizardStepper' -import Step1BasicInfo from '@/components/events/create/consumer/steps/Step1BasicInfo' -import Step2ScheduleLocation from '@/components/events/create/consumer/steps/Step2ScheduleLocation' -import Step3PricingCapacity from '@/components/events/create/consumer/steps/Step3PricingCapacity' -import Step4FaqPreview from '@/components/events/create/consumer/steps/Step4FaqPreview' -import Step5GuestList, { loadPreviousAttendeesForStep5 } from '@/components/events/create/consumer/steps/Step5GuestList' -import { mapEventToWizardCloneData } from '@/components/events/create/consumer/cloneFromEvent' -import { DetailSkeleton } from '@/components/feedback/LoadingState' -import { - combineDateAndMinutes, - hasRequiredPosters, - INITIAL_WIZARD_DATA, - type EventWizardFormData, -} from '@/components/events/create/consumer/types' -import { CONSUMER_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' -import AngleLeftIcon from '@/components/icons/AngleLeftIcon' -import CloseLinearIcon from '@/components/icons/CloseLinearIcon' - -interface HostCityPrefill { - cityId: string - provinceId: string - address?: string - lat?: number - lng?: number -} - -interface ConsumerEventCreateWizardProps { - finishRoute?: string -} - -export default function ConsumerEventCreateWizard({ finishRoute = CONSUMER_ROUTES.MY_EVENTS }: ConsumerEventCreateWizardProps = {}) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const cloneFromIdRef = useRef(searchParams.get('cloneFrom')) - const hasTrackedStartRef = useRef(false) - const stepSubmitRef = useRef<(() => void) | null>(null) - const stepScrollRef = useRef(null) - 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 [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: 'consumer', - is_clone: Boolean(cloneFromIdRef.current), - }) - }, [userId]) - - const categoriesQuery = usePublicCategoriesQuery() - const categories = useMemo(() => categoriesQuery.data?.items ?? [], [categoriesQuery.data]) - const [cities, setCities] = useState([]) - const [hostCityName, setHostCityName] = useState() - - useEffect(() => { - if (!userId) return - - let cancelled = false - const cloneFromId = cloneFromIdRef.current - - const resolveHostCity = async (): Promise => { - const [meResult, cityRows] = await Promise.all([GET_ME(), fetchAllCities()]) - - if (cancelled) return null - - setCities(cityRows) - - if (!meResult.ok || !meResult.data.cityId) return null - - const city = cityRows.find((row) => row.id === meResult.data.cityId) - - if (!city) return null - - setHostCityName(city.name || meResult.data.cityName || undefined) - - return { - cityId: String(city.id), - provinceId: String(city.provinceId), - address: meResult.data.defaultAddress || undefined, - lat: city.lat, - lng: city.lng, - } - } - - const applyHostCity = ( - base: EventWizardFormData, - hostCity: HostCityPrefill | null, - options?: { preferHostAddress?: boolean; preferHostMapCenter?: boolean } - ): EventWizardFormData => { - if (!hostCity) return base - - return { - ...base, - cityId: hostCity.cityId, - provinceId: hostCity.provinceId, - ...(options?.preferHostAddress && hostCity.address ? { address: hostCity.address } : {}), - ...(options?.preferHostMapCenter && hostCity.lat != null && hostCity.lng != null ? { lat: hostCity.lat, lng: hostCity.lng } : {}), - } - } - - const hydrate = async () => { - let hostCity: HostCityPrefill | null = null - - try { - hostCity = await resolveHostCity() - } catch { - // Prefill is best-effort; wizard still opens without city until save guard. - } - - if (cancelled) return - - if (cloneFromId) { - try { - const [event, media, faqs] = await Promise.all([ - fetchEventForEdit(cloneFromId), - fetchEventMedia(cloneFromId).catch(() => []), - fetchEventFaqs(cloneFromId).catch(() => []), - ]) - - if (cancelled) return - - clearEventDraft(userId) - setData(applyHostCity(mapEventToWizardCloneData(event, media, faqs), hostCity)) - setStep(1) - setIsDraftHydrated(true) - router.replace(pathname) - - return - } catch { - if (cancelled) return - addToast({ title: texts.events.wizardCloneLoadFailed, color: 'danger' }) - } - } - - const stored = loadEventDraft(userId) - - if (stored) { - setData(applyHostCity({ ...INITIAL_WIZARD_DATA, ...stored.data }, hostCity)) - setStep(stored.step) - setIsDraftHydrated(true) - - return - } - - setData( - applyHostCity(INITIAL_WIZARD_DATA, hostCity, { - preferHostAddress: true, - preferHostMapCenter: true, - }) - ) - 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]) - - // کانتینر اسکرول والد است و با عوض شدن step unmount نمی‌شود؛ باید دستی به بالا برگردد - useEffect(() => { - const el = stepScrollRef.current - - if (!el) return - el.scrollTop = 0 - }, [step]) - - const patchData = useCallback((patch: Partial) => { - setData((prev) => ({ ...prev, ...patch })) - }, []) - - const bindStepSubmit = useCallback((submit: (() => void) | null) => { - stepSubmitRef.current = submit - }, []) - - const categoryName = useMemo(() => categories.find((item) => String(item.id) === data.categoryId)?.name, [categories, data.categoryId]) - const cityName = useMemo( - () => hostCityName ?? cities.find((item) => String(item.id) === data.cityId)?.name, - [cities, data.cityId, hostCityName] - ) - - const submitCurrentStep = useCallback(() => { - stepSubmitRef.current?.() - }, []) - - const handleSaveDraft = async () => { - if (isSaving) return - - if (!hasRequiredPosters(data.media)) { - addToast({ - title: texts.events.postersRequiredTitle, - description: texts.events.postersRequiredDescription, - color: 'danger', - }) - setStep(1) - - return - } - - const cityId = Number(data.cityId) - const provinceId = Number(data.provinceId) - - if (!Number.isFinite(cityId) || cityId < 1 || !Number.isFinite(provinceId) || provinceId < 1) { - addToast({ title: texts.events.hostCityMissing, color: 'danger' }) - setStep(2) - - 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, - 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, - 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 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(CONSUMER_ROUTES.EVENT_DETAIL(eventId)) - - return - } - - addToast({ - title: texts.events.draftSaveFailed, - description: detail ?? undefined, - color: 'danger', - }) - } finally { - setIsSaving(false) - } - } - - if (!userId || !isDraftHydrated) { - return ( -
- -
- ) - } - - return ( -
-
- -
- -
- {step === 1 && ( - { - patchData(patch) - setStep(2) - }} - /> - )} - - {step === 2 && ( - { - patchData(patch) - setStep(3) - }} - /> - )} - - {step === 3 && ( - { - patchData(patch) - setStep(4) - }} - /> - )} - - {step === 4 && ( - void handleSaveDraft()} - /> - )} -
- -
- : } - cancelLabel={step === 1 ? texts.common.cancel : texts.events.return} - className="w-full rounded-consumer-modal bg-white p-4" - isPrimaryLoading={step === 4 && isSaving} - primaryLabel={step === 4 ? texts.events.saveDraft : texts.events.saveAndContinue} - primaryType="button" - onCancel={() => { - if (step === 1) { - router.push(finishRoute) - - return - } - - setStep((current) => current - 1) - }} - onPrimary={submitCurrentStep} - /> -
- - {createdEventId ? ( - { - router.push(finishRoute) - }} - onOpenChange={setShowPostCreateInvite} - /> - ) : null} -
- ) -} diff --git a/components/events/create/consumer/ConsumerWizardStepper.tsx b/components/events/create/consumer/ConsumerWizardStepper.tsx deleted file mode 100644 index 89a98b4..0000000 --- a/components/events/create/consumer/ConsumerWizardStepper.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import Link from 'next/link' - -import CalendarFillIcon from '@/components/icons/CalendarFillIcon' -import HomeFillIcon from '@/components/icons/HomeFillIcon' -import { CONSUMER_ROUTES } from '@/constants/routes' -import { cn } from '@/lib/cn' -import { texts } from '@/texts' - -interface ConsumerWizardStepperProps { - currentStep: number - className?: string -} - -const STEPS = [ - texts.events.wizardProgressStep1, - texts.events.wizardProgressStep2, - texts.events.wizardProgressStep3, - texts.events.wizardProgressStep4, -] as const - -const ConsumerWizardStepper = ({ currentStep, className }: ConsumerWizardStepperProps) => { - const progress = ((currentStep - 1) / (STEPS.length - 1)) * 75 - - return ( - - ) -} - -export default ConsumerWizardStepper diff --git a/components/events/create/consumer/cloneFromEvent.ts b/components/events/create/consumer/cloneFromEvent.ts deleted file mode 100644 index e58f456..0000000 --- a/components/events/create/consumer/cloneFromEvent.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { EventWizardFormData } from '@/components/events/create/consumer/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, - 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/consumer/steps/Step1BasicInfo.tsx b/components/events/create/consumer/steps/Step1BasicInfo.tsx deleted file mode 100644 index 2f94d1e..0000000 --- a/components/events/create/consumer/steps/Step1BasicInfo.tsx +++ /dev/null @@ -1,181 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { useEffect, useMemo, useRef } from 'react' -import { FormProvider, useForm } from 'react-hook-form' - -import { texts, format } from '@/texts' -import type { EventWizardEditLocks, EventWizardFormData } from '@/components/events/create/consumer/types' -import { hasRequiredPosters } from '@/components/events/create/consumer/types' -import { addToast } from '@/lib/toast' -import EventMediaGalleryUploader from '@/components/events/create/EventMediaGalleryUploader' -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' -import ConsumerInput from '@/components/consumer/ConsumerInput' -import { buildCategoryTree } from '@/helpers/categoryPath' - -interface Step1BasicInfoProps { - data: EventWizardFormData - /** Present in edit mode; media stays editable regardless of locks. */ - locks?: EventWizardEditLocks - onBindSubmit: (submit: (() => void) | null) => void - onChange: (patch: Partial) => void - onNext: (patch: Partial) => void -} - -export default function Step1BasicInfo({ data, onBindSubmit, onChange, onNext }: Step1BasicInfoProps) { - const categoriesQuery = usePublicCategoriesQuery() - const categoryOptions = useMemo( - () => - buildCategoryTree(categoriesQuery.data?.items ?? []).map((option) => ({ - depth: option.depth, - id: String(option.id), - name: option.name, - })), - [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) - - useEffect(() => { - onBindSubmit(() => { - void handleSubmit() - }) - - return () => { - onBindSubmit(null) - } - }, [handleSubmit, onBindSubmit]) - - return ( - -
{ - event.preventDefault() - void handleSubmit() - }} - > - - - - - { - onChange({ media }) - }} - /> - - - - - - - -
- ) -} diff --git a/components/events/create/consumer/steps/Step2ScheduleLocation.tsx b/components/events/create/consumer/steps/Step2ScheduleLocation.tsx deleted file mode 100644 index 7c299ec..0000000 --- a/components/events/create/consumer/steps/Step2ScheduleLocation.tsx +++ /dev/null @@ -1,221 +0,0 @@ -'use client' - -import dynamic from 'next/dynamic' -import { useCallback, useEffect, useState } from 'react' - -import { texts } from '@/texts' -import type { EventWizardEditLocks, EventWizardFormData } from '@/components/events/create/consumer/types' -import ConsumerInput from '@/components/consumer/ConsumerInput' -import InlineNotice from '@/components/feedback/InlineNotice' -import MapLocationPicker from '@/components/events/create/MapLocationPicker' -import { useEventScheduleConstraints } from '@/components/events/schedule/eventScheduleConstraints' -import { parseConsumerEventWizardStep2, type ConsumerEventWizardStep2FieldErrors } from '@/validation/consumerEventWizardStep2' -import { addToast } from '@/lib/toast' - -const EventScheduleFieldControl = dynamic(() => import('@/components/events/schedule/EventScheduleFieldControl'), { - ssr: false, -}) - -interface Step2ScheduleLocationProps { - data: EventWizardFormData - locks?: EventWizardEditLocks - onBindSubmit: (submit: (() => void) | null) => void - onChange: (patch: Partial) => void - onNext: (patch: Partial) => void -} - -export default function Step2ScheduleLocation({ data, locks, onBindSubmit, onChange, onNext }: Step2ScheduleLocationProps) { - const [errors, setErrors] = useState({}) - const { todayIso, startMinimumTime, endMinimumTime } = useEventScheduleConstraints(data.startDate, data.startTime, data.endDate) - const scheduleLocked = Boolean(locks?.schedule) - const locationLocked = Boolean(locks?.location) - const showLockNotice = Boolean(locks?.notice && (scheduleLocked || locationLocked)) - - const patch = (next: Partial) => { - setErrors({}) - onChange(next) - } - - const handleSubmit = useCallback(() => { - const result = parseConsumerEventWizardStep2({ - startDate: data.startDate, - startTime: data.startTime, - endDate: data.endDate, - endTime: data.endTime, - address: data.address, - addressVisibility: data.addressVisibility, - generalArea: data.addressVisibility === 'attendees_only' ? data.generalArea : '', - }) - - if (!result.success) { - setErrors(result.fieldErrors) - addToast({ - title: texts.common.formIncomplete, - description: Object.values(result.fieldErrors).find(Boolean) ?? texts.common.formValidationDefault, - color: 'danger', - }) - - return - } - - setErrors({}) - onNext({ - ...result.data, - lat: data.lat, - lng: data.lng, - generalArea: result.data.addressVisibility === 'attendees_only' ? (result.data.generalArea?.trim() ?? '') : '', - }) - }, [data, onNext]) - - useEffect(() => { - onBindSubmit(handleSubmit) - - return () => { - onBindSubmit(null) - } - }, [handleSubmit, onBindSubmit]) - - return ( -
{ - event.preventDefault() - handleSubmit() - }} - > - {showLockNotice ? {locks?.notice} : null} - -
-
- {texts.events.scheduleSectionTitle} - {texts.events.scheduleSectionDescription} -
- { - if (scheduleLocked) return - patch({ startDate: String(value) }) - }} - /> - { - if (scheduleLocked) return - patch({ startTime: Number(value) }) - }} - /> - { - if (scheduleLocked) return - patch({ endDate: String(value) }) - }} - /> - { - if (scheduleLocked) return - patch({ endTime: Number(value) }) - }} - /> -
- -
- { - if (locationLocked) return - patch({ address: typeof value === 'string' ? value : '' }) - }} - /> - {errors.address ?

{errors.address}

: null} -
- - { - if (locationLocked) return - patch({ address }) - }} - onChange={({ lat, lng }) => { - if (locationLocked) return - patch({ lat, lng }) - }} - /> - - { - if (locationLocked) return - patch({ - addressVisibility: checked ? 'attendees_only' : 'public', - generalArea: checked ? data.generalArea : '', - }) - }} - /> - - {data.addressVisibility === 'attendees_only' ? ( -
- { - if (locationLocked) return - patch({ generalArea: typeof value === 'string' ? value : '' }) - }} - /> - {errors.generalArea ?

{errors.generalArea}

: null} -
- ) : null} - - ) -} diff --git a/components/events/create/consumer/steps/Step3PricingCapacity.tsx b/components/events/create/consumer/steps/Step3PricingCapacity.tsx deleted file mode 100644 index ff1d9d0..0000000 --- a/components/events/create/consumer/steps/Step3PricingCapacity.tsx +++ /dev/null @@ -1,226 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { useEffect } from 'react' -import { FormProvider, useForm } from 'react-hook-form' - -import { texts, format } from '@/texts' -import type { EventWizardEditLocks, EventWizardFormData } from '@/components/events/create/consumer/types' -import AudienceRestrictionSelector from '@/components/events/create/AudienceRestrictionSelector' -import TicketTypeSelector from '@/components/events/create/TicketTypeSelector' -import ConsumerInput from '@/components/consumer/ConsumerInput' -import InlineNotice from '@/components/feedback/InlineNotice' -import { EventWizardStep3Validation, type EventWizardStep3Values } from '@/validation/eventWizard' -import { showFormValidationToast } from '@/lib/formValidationToast' -import { addToast } from '@/lib/toast' - -interface Step3PricingCapacityProps { - data: EventWizardFormData - locks?: EventWizardEditLocks - onBindSubmit: (submit: (() => void) | null) => void - onChange: (patch: Partial) => void - onNext: (patch: Partial) => void -} - -export default function Step3PricingCapacity({ data, locks, onBindSubmit, onChange, onNext }: Step3PricingCapacityProps) { - const pricingLocked = Boolean(locks?.pricing) - const audienceLocked = Boolean(locks?.audience) - const cancellationLocked = Boolean(locks?.cancellation) - const behavioralLocked = Boolean(locks?.behavioralSettings) - const minCapacity = locks?.minCapacity ?? 1 - const showLockNotice = Boolean(locks?.notice && (pricingLocked || audienceLocked || cancellationLocked || behavioralLocked)) - - 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, - 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), - 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) => { - if (values.capacity < minCapacity) { - addToast({ - title: format(texts.events.capacityBelowOccupied, { min: minCapacity }), - color: 'danger', - }) - - return - } - - 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, - isDiscoverable: values.isDiscoverable, - autoCreateGroup: values.autoCreateGroup, - waitlistAutoOffer: values.waitlistAutoOffer, - sendReviewRequestSms: values.sendReviewRequestSms, - }) - }, showFormValidationToast) - - useEffect(() => { - onBindSubmit(() => { - void handleSubmit() - }) - - return () => { - onBindSubmit(null) - } - }, [handleSubmit, onBindSubmit]) - - return ( - -
{ - event.preventDefault() - void handleSubmit() - }} - > - {showLockNotice ? {locks?.notice} : null} - -
- ایجاد بلیط - - نوع ثبت‌نام را انتخاب کن؛ برای رویداد پولی، مبلغ نهایی بلیط را وارد کن. - -
- { - if (pricingLocked) return - form.setValue('isFree', nextIsFree, { shouldDirty: true, shouldValidate: true }) - }} - /> - - {!isFree ? ( - - ) : null} - - 1 ? format(texts.events.capacityMinOccupiedHint, { min: minCapacity }) : undefined} - generalType="numberInput" - label={texts.events.capacity} - minValue={minCapacity} - name="capacity" - /> - - -
- شرکت کنندگان - - اگر رویداد برای همه نیست، محدودیت جنسیت یا سن را انتخاب کن. پیش‌فرض آزاد است. - -
- - { - if (audienceLocked) return - form.setValue('ageRestriction', ageRestriction, { shouldDirty: true, shouldValidate: true }) - }} - onGenderRestrictionChange={(genderRestriction) => { - if (audienceLocked) return - form.setValue('genderRestriction', genderRestriction, { shouldDirty: true, shouldValidate: true }) - }} - /> - - - -
- - {texts.events.moreSettings} - -
- - - - -
-
- -
- ) -} diff --git a/components/events/create/consumer/steps/Step4FaqPreview.tsx b/components/events/create/consumer/steps/Step4FaqPreview.tsx deleted file mode 100644 index 2a60920..0000000 --- a/components/events/create/consumer/steps/Step4FaqPreview.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client' - -import { useEffect } from 'react' - -import CreateEventPreviewCard from '@/components/events/create/CreateEventPreviewCard' -import FaqEditor from '@/components/events/create/FaqEditor' -import { - combineDateAndMinutes, - formatWizardDate, - type EventWizardEditLocks, - type EventWizardFormData, -} from '@/components/events/create/consumer/types' - -interface Step4FaqPreviewProps { - data: EventWizardFormData - categoryName?: string - // استان فعلاً در پیش‌نمایش نمایش داده نمی‌شود. - // provinceName?: string - cityName?: string - /** Present in edit mode; FAQs stay editable regardless of locks. */ - locks?: EventWizardEditLocks - onBindSubmit: (submit: (() => void) | null) => void - onChange: (patch: Partial) => void - onSaveDraft: () => void -} - -export default function Step4FaqPreview({ - data, - categoryName, - // provinceName, - cityName, - onBindSubmit, - 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 - } - - useEffect(() => { - onBindSubmit(onSaveDraft) - - return () => { - onBindSubmit(null) - } - }, [onBindSubmit, onSaveDraft]) - - return ( - <> -
- -
-
- { - onChange({ faqs }) - }} - /> -
- - ) -} diff --git a/components/events/create/consumer/steps/Step5GuestList.tsx b/components/events/create/consumer/steps/Step5GuestList.tsx deleted file mode 100644 index 5bb421c..0000000 --- a/components/events/create/consumer/steps/Step5GuestList.tsx +++ /dev/null @@ -1,161 +0,0 @@ -'use client' - -import { useEffect, useMemo, useState } from 'react' - -import ConsumerInput from '@/components/consumer/ConsumerInput' -import ConsumerModal from '@/components/consumer/ConsumerModal' -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()} - > -
-

{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/consumer/types.ts b/components/events/create/consumer/types.ts deleted file mode 100644 index 5002037..0000000 --- a/components/events/create/consumer/types.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { formatNumber } from '@/helpers' -import { formatPersianDate } from '@/lib/formatters' -import type { EventAgeRestriction, EventGenderRestriction } from '@/lib/eventAudience' -import { texts, format } from '@/texts' - -export interface StagedMediaItem { - id: string - url: string - isPoster: boolean - isSquarePoster: boolean - sortOrder: number -} - -export function hasRequiredPosters(media: StagedMediaItem[]): boolean { - return media.some((item) => item.isPoster) && media.some((item) => item.isSquarePoster) -} - -export interface FaqItem { - id: string - question: string - answer: string -} - -/** Optional field locks for the consumer edit wizard. Omit (= undefined) keeps create-mode behavior. */ -export interface EventWizardEditLocks { - schedule?: boolean - location?: boolean - pricing?: boolean - audience?: boolean - cancellation?: boolean - behavioralSettings?: boolean - minCapacity?: number - /** Shown at the top of steps that contain locked fields. */ - notice?: 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 - 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: 0, - // 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) - - if (!Number.isFinite(date.getTime())) { - throw new Error(texts.events.invalidDate) - } - - const hours = Math.floor(minutes / 60) - const mins = minutes % 60 - - date.setHours(hours, mins, 0, 0) - - 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/components/reviews/EventReviewsSection.tsx b/components/reviews/EventReviewsSection.tsx deleted file mode 100644 index ad5b3ef..0000000 --- a/components/reviews/EventReviewsSection.tsx +++ /dev/null @@ -1,337 +0,0 @@ -'use client' - -import { useMemo, useState } from 'react' -import { useQueryClient } from '@tanstack/react-query' - -import Button from '@/components/formElements/Button' -import ConsumerInput from '@/components/consumer/ConsumerInput' -import ConsumerModal from '@/components/consumer/ConsumerModal' -import AddDocumentIcon from '@/components/icons/AddDocumentIcon' -import HeatIconFill from '@/components/icons/HeatIconFill' -import ReviewCard from '@/components/reviews/ReviewCard' -import StarRating from '@/components/reviews/StarRating' -import { coerceToString } from '@/helpers' -import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics' -import { isWithinHostReplyWindow, isWithinReviewWindow } from '@/lib/reviewWindow' -import { addToast } from '@/lib/toast' -import useAuth from '@/hooks/useAuth' -import { useEventReviewsInfiniteQuery } from '@/queries/consumer/useEventDetailQueries' -import { useEventViewerStateQuery } from '@/queries/consumer/useEventViewerStateQuery' -import { consumerKeys } from '@/queries/consumerKeys' -import { CREATE_REVIEW, HOST_REPLY_REVIEW, type EventReview } from '@/services/reviews' -import { texts, format } from '@/texts' - -interface EventReviewsSectionProps { - eventId: string - eventStatus: string - /** Scheduled event end — review window is endsAt + 1 month; host reply + 2 months. */ - endsAt: string - avgRating?: number | null - reviewsCount?: number - initialReviews?: EventReview[] - reviewsTotal?: number - /** Event organizer — used to enable host reply controls. */ - organizerId?: string -} - -const authorDisplayName = (review: EventReview) => { - const name = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ').trim() - - return name || texts.events.guest -} - -const EventReviewsSection = ({ - eventId, - eventStatus, - endsAt, - avgRating: initialAvg, - reviewsCount: initialCount, - initialReviews, - reviewsTotal: initialTotal, - organizerId, -}: EventReviewsSectionProps) => { - const { user } = useAuth() - const queryClient = useQueryClient() - const viewerStateQuery = useEventViewerStateQuery(eventId) - const [draftBody, setDraftBody] = useState('') - const [draftRating, setDraftRating] = useState(0) - const [isSaving, setIsSaving] = useState(false) - const [replyTarget, setReplyTarget] = useState(null) - const [replyBody, setReplyBody] = useState('') - const [isSavingReply, setIsSavingReply] = useState(false) - const isHost = Boolean(organizerId && user?.userId && user.userId === organizerId) - - const reviewsSeed = - initialReviews !== undefined - ? { items: initialReviews, totalItemsCount: initialTotal ?? initialCount ?? initialReviews.length } - : undefined - const reviewsQuery = useEventReviewsInfiniteQuery(eventId, reviewsSeed) - - const reviews = useMemo(() => { - const uniqueReviews = new Map() - - for (const page of reviewsQuery.data?.pages ?? []) { - for (const review of page.items) uniqueReviews.set(review.id, review) - } - - return [...uniqueReviews.values()] - }, [reviewsQuery.data?.pages]) - const reviewsTotal = reviewsQuery.data?.pages.at(-1)?.totalItemsCount ?? initialTotal ?? initialCount ?? 0 - const isLoading = reviewsQuery.isPending && reviewsQuery.fetchStatus === 'fetching' - const isLoadingMore = reviewsQuery.isFetchingNextPage - - const bookingId = - eventStatus === 'completed' && viewerStateQuery.data?.activeBooking?.status === 'confirmed' - ? viewerStateQuery.data.activeBooking.id - : null - - const myReview = reviews.find((review) => review.userId === user?.userId) ?? null - const windowOpen = isWithinReviewWindow(endsAt) - const canReview = eventStatus === 'completed' && !!bookingId && !myReview && !!user?.userId && windowOpen - const showReviewWindowExpired = eventStatus === 'completed' && !!bookingId && !myReview && !!user?.userId && !windowOpen - const replyWindowOpen = isWithinHostReplyWindow(endsAt) - const canHostReply = isHost && replyWindowOpen - - const avgRating = - initialAvg ?? (reviews.length > 0 ? Math.round((reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length) * 10) / 10 : null) - const reviewsCount = reviewsTotal - - const reloadReviews = () => { - void queryClient.invalidateQueries({ queryKey: consumerKeys.eventReviewsRoot(eventId) }) - void queryClient.invalidateQueries({ queryKey: consumerKeys.eventDetailRoot(eventId) }) - } - - const handleSubmitReview = async () => { - if (!bookingId) return - if (draftRating < 1 || draftRating > 5) { - addToast({ title: texts.reviews.selectRating, color: 'warning' }) - - return - } - - setIsSaving(true) - const trimmed = draftBody.trim() - const result = await CREATE_REVIEW(eventId, { - bookingId, - rating: draftRating, - body: trimmed.length > 0 ? trimmed : null, - }) - - setIsSaving(false) - if (!result.ok) return - - trackAnalyticsEventOnce(ANALYTICS_EVENTS.REVIEW_SUBMITTED, result.data.id, { - review_id: result.data.id, - event_id: eventId, - booking_id: bookingId, - rating: draftRating, - has_comment: trimmed.length > 0, - }) - - addToast({ title: texts.reviews.submitted, color: 'success' }) - setDraftBody('') - setDraftRating(0) - reloadReviews() - } - - const openReply = (review: EventReview) => { - if (!replyWindowOpen) { - addToast({ title: texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED, color: 'warning' }) - - return - } - setReplyTarget(review) - setReplyBody(review.hostReplyBody ?? '') - } - - const saveReply = async () => { - if (!replyTarget) return - const trimmed = replyBody.trim() - - if (!trimmed) { - addToast({ title: texts.reviews.replyBodyRequired, color: 'warning' }) - - return - } - - setIsSavingReply(true) - const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: trimmed }) - - setIsSavingReply(false) - if (!result.ok) return - - addToast({ title: texts.reviews.replySaved, color: 'success' }) - setReplyTarget(null) - reloadReviews() - } - - const clearReply = async () => { - if (!replyTarget) return - setIsSavingReply(true) - const result = await HOST_REPLY_REVIEW(replyTarget.id, { hostReplyBody: null }) - - setIsSavingReply(false) - if (!result.ok) return - - addToast({ title: texts.reviews.replyDeleted, color: 'success' }) - setReplyTarget(null) - reloadReviews() - } - - return ( -
-
-

{texts.reviews.peopleSayTitle}

- {reviewsCount > 0 && avgRating != null ? ( -
- - - {format(texts.reviews.avgFromCount, { - rating: avgRating.toLocaleString('fa-IR', { maximumFractionDigits: 1 }), - count: reviewsCount.toLocaleString('fa-IR'), - })} - -
- ) : null} -
- - {showReviewWindowExpired ? ( -

{texts.errors.codes.REVIEW_WINDOW_EXPIRED}

- ) : null} - - {isLoading ?

{texts.reviews.loading}

: null} - - {!isLoading && reviews.length === 0 ? ( -
-

{texts.reviews.eventEmptyTitle}

-

{texts.reviews.eventEmptyHint}

-
- ) : null} - - {!isLoading && reviews.length > 0 ? ( -
    - {reviews.map((review, index) => ( - { - openReply(review) - }} - > - {review.hostReplyBody ? texts.reviews.editReply : texts.common.reply} - - ) : null - } - hostReplyBody={review.hostReplyBody} - rating={review.rating} - tone="consumer" - /> - ))} -
- ) : null} - - {!isLoading && reviewsQuery.hasNextPage ? ( - - ) : null} - - {canReview ? ( -
-
-

{texts.reviews.shareExperienceTitle}

- { - setDraftBody(coerceToString(next)) - }} - /> -
-
- - -
-
- ) : null} - - {isHost && !replyWindowOpen && reviews.length > 0 ? ( -

{texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED}

- ) : null} - - void clearReply()} - > - {texts.reviews.deleteReply} - - ) : null - } - isLoading={isSavingReply} - 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)) - }} - /> - -
- ) -} - -export default EventReviewsSection diff --git a/components/reviews/OrganizerReviewsSection.test.tsx b/components/reviews/OrganizerReviewsSection.test.tsx deleted file mode 100644 index 1a5d46b..0000000 --- a/components/reviews/OrganizerReviewsSection.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -import OrganizerReviewsSection from '@/components/reviews/OrganizerReviewsSection' -import type * as ReviewsService from '@/services/reviews' -import type { OrganizerReview } from '@/services/reviews' - -const listPage = vi.fn() - -vi.mock('@/services/reviews', async (importOriginal) => { - const original = await importOriginal() - - return { - ...original, - LIST_ORGANIZER_REVIEWS_PAGE: (...args: unknown[]) => listPage(...args), - } -}) -vi.mock('@/components/reviews/ReviewCard', () => ({ - default: ({ body }: { body: string | null }) =>
  • {body}
  • , -})) -vi.mock('@/components/reviews/StarRating', () => ({ default: () => })) - -const review = (id: string): OrganizerReview => - ({ - id, - body: `review-${id}`, - eventSlug: 'event', - eventTitle: 'Event', - }) as OrganizerReview - -describe('OrganizerReviewsSection', () => { - afterEach(() => { - cleanup() - vi.clearAllMocks() - }) - - it('uses SSR reviews without a hydration request and appends only the next page', async () => { - const initialReviews = Array.from({ length: 10 }, (_, index) => review(String(index + 1))) - - listPage.mockResolvedValue({ - ok: true, - data: { items: [review('11'), review('12')], page: 2, totalItemsCount: 12, totalPages: 2 }, - }) - - render( - - ) - - expect(listPage).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'نمایش نظرات بیشتر' })) - - await waitFor(() => { - expect(listPage).toHaveBeenCalledWith('organizer-1', 2, 10, expect.objectContaining({ errorMode: 'silent' })) - }) - expect(await screen.findByText('review-12')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'نمایش نظرات بیشتر' })).not.toBeInTheDocument() - }) -}) diff --git a/components/reviews/OrganizerReviewsSection.tsx b/components/reviews/OrganizerReviewsSection.tsx deleted file mode 100644 index a0582e5..0000000 --- a/components/reviews/OrganizerReviewsSection.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client' - -import { useState } from 'react' -import Link from 'next/link' - -import ConsumerState from '@/components/feedback/ConsumerState' -import ReviewCard from '@/components/reviews/ReviewCard' -import HeatIconFill from '@/components/icons/HeatIconFill' -import { SEO_ROUTES } from '@/constants/routes' -import Button from '@/components/formElements/Button' -import { LIST_ORGANIZER_REVIEWS_PAGE, ORGANIZER_REVIEWS_PAGE_SIZE, type OrganizerReview } from '@/services/reviews' -import { texts, format } from '@/texts' - -interface OrganizerReviewsSectionProps { - organizerId: string - avgRating?: number | null - reviewsCount?: number - initialReviews?: OrganizerReview[] -} - -const OrganizerReviewsSection = ({ organizerId, avgRating, reviewsCount = 0, initialReviews = [] }: OrganizerReviewsSectionProps) => { - const [reviews, setReviews] = useState(initialReviews) - const [page, setPage] = useState(1) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const hasMore = reviews.length < reviewsCount - - const loadMore = async () => { - if (!hasMore || isLoadingMore) return - setIsLoadingMore(true) - const nextPage = page + 1 - const result = await LIST_ORGANIZER_REVIEWS_PAGE(organizerId, nextPage, ORGANIZER_REVIEWS_PAGE_SIZE, { errorMode: 'silent' }) - - if (result.ok) { - setReviews((current) => { - const seen = new Set(current.map((review) => review.id)) - - return [...current, ...result.data.items.filter((review) => !seen.has(review.id))] - }) - setPage(nextPage) - } - setIsLoadingMore(false) - } - - return ( -
    -
    -
    -

    {texts.reviews.guestReviewsTitle}

    - {reviewsCount && reviewsCount > 0 && avgRating != null ? ( -
    - - {avgRating.toLocaleString('fa-IR', { maximumFractionDigits: 1 })} - - {format(texts.events.reviewCountParen, { count: reviewsCount.toLocaleString('fa-IR') })} - -
    - ) : ( -

    {texts.reviews.organizerEmptyHint}

    - )} -
    -
    - - {reviews.length === 0 ? ( - - ) : ( - <> -
      - {reviews.map((review) => ( - - {review.eventTitle} - - } - hostReplyBody={review.hostReplyBody} - rating={review.rating} - tone="organizer" - /> - ))} -
    - {hasMore ? ( -
    - -
    - ) : null} - - )} -
    - ) -} - -export default OrganizerReviewsSection diff --git a/components/reviews/ReviewModal.tsx b/components/reviews/ReviewModal.tsx deleted file mode 100644 index a7d76a8..0000000 --- a/components/reviews/ReviewModal.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' - -import { coerceToString } from '@/helpers' -import { addToast } from '@/lib/toast' -import ConsumerInput from '@/components/consumer/ConsumerInput' -import ConsumerModal from '@/components/consumer/ConsumerModal' -import StarRating from '@/components/reviews/StarRating' -import { CREATE_REVIEW, type EventReview } from '@/services/reviews' -import { ANALYTICS_EVENTS, trackAnalyticsEventOnce } from '@/lib/analytics' -import { texts } from '@/texts' - -interface ReviewModalProps { - isOpen: boolean - onOpenChange: (open: boolean) => void - eventId: string - bookingId: string - onSaved?: (review: EventReview) => void -} - -/** Create-only review modal (booking surfaces). Event detail uses inline compose instead. */ -const ReviewModal = ({ isOpen, onOpenChange, eventId, bookingId, onSaved }: ReviewModalProps) => { - const [rating, setRating] = useState(0) - const [body, setBody] = useState('') - const [isSaving, setIsSaving] = useState(false) - - useEffect(() => { - if (!isOpen) return - setRating(0) - setBody('') - }, [isOpen]) - - const handleSave = async () => { - if (rating < 1 || rating > 5) { - addToast({ title: texts.reviews.selectRating, color: 'warning' }) - - return - } - - setIsSaving(true) - const trimmed = body.trim() - const result = await CREATE_REVIEW(eventId, { - bookingId, - rating, - body: trimmed.length > 0 ? trimmed : null, - }) - - setIsSaving(false) - - if (!result.ok) return - - trackAnalyticsEventOnce(ANALYTICS_EVENTS.REVIEW_SUBMITTED, result.data.id, { - review_id: result.data.id, - event_id: eventId, - booking_id: bookingId, - rating, - has_comment: trimmed.length > 0, - }) - - addToast({ - title: texts.reviews.submitted, - color: 'success', - }) - onSaved?.(result.data) - onOpenChange(false) - } - - return ( - void handleSave()} - onOpenChange={onOpenChange} - onReject={() => { - onOpenChange(false) - }} - > -
    -
    -

    {texts.reviews.yourRating}

    - -
    - { - setBody(coerceToString(next)) - }} - /> -

    {texts.reviews.ratingOnlyHint}

    -
    -
    - ) -} - -export default ReviewModal diff --git a/config/site.tsx b/config/site.tsx index 12d7e59..5241be8 100644 --- a/config/site.tsx +++ b/config/site.tsx @@ -159,7 +159,7 @@ export const siteConfig = (): { userSidebar: SidebarRoute[] } => ({ export const metadata: Metadata = { title: { default: 'قبیله — پنل مدیریت', - template: `%s - قبیله`, + template: `%s | قبیله Backoffice`, }, description: 'پنل مدیریت پلتفرم قبیله', icons: { diff --git a/constants/adminPageTitles.ts b/constants/adminPageTitles.ts new file mode 100644 index 0000000..3aeb5bd --- /dev/null +++ b/constants/adminPageTitles.ts @@ -0,0 +1,37 @@ +/** + * Browser tab titles for admin routes — keep in sync with `config/site.tsx` menu labels. + */ +export const ADMIN_PAGE_TITLES = { + auth: 'لاگین', + dashboard: 'پیشخوان', + users: 'کاربران', + userDetail: 'جزئیات کاربر', + manageEvents: 'رویدادها', + manageEventNew: 'رویداد جدید', + manageEventDetail: 'جزئیات رویداد', + manageEventEdit: 'ویرایش رویداد', + discountCodes: 'کد تخفیف', + payments: 'پرداخت‌ها', + walletDeposits: 'واریزهای کیف پول', + bookings: 'رزروها', + settlements: 'تسویه‌ها', + withdrawalRequests: 'درخواست‌های برداشت', + bankAccounts: 'حساب‌های بانکی', + eventCategories: 'دسته‌بندی‌ها', + blogArticles: 'مقالات وبلاگ', + guestLists: 'لیست‌های مهمان', + reviews: 'نظرات', + cities: 'شهرها', + provinces: 'استان‌ها', + identityVerifications: 'احراز هویت میزبان', + notifications: 'اعلان‌ها', + userReports: 'گزارش‌های کاربران', + contactMessages: 'پیام‌های تماس', + supportTickets: 'تیکت‌های پشتیبانی', + supportTicketDetail: 'جزئیات تیکت', + chatOversight: 'نظارت بر گفتگوها', + chatOversightDetail: 'جزئیات گفتگو', + auditLogs: 'گزارش فعالیت‌های ادمین', +} as const + +export type AdminPageTitleKey = keyof typeof ADMIN_PAGE_TITLES diff --git a/constants/host-contract.ts b/constants/host-contract.ts deleted file mode 100644 index 1ce46ba..0000000 --- a/constants/host-contract.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Immutable identifier for the host cooperation / commission contract - * currently presented during host identity verification. Must match - * backend `CURRENT_HOST_CONTRACT_VERSION`. - */ -export const CURRENT_HOST_CONTRACT_VERSION = '2026-08-22' diff --git a/content/aboutPage.json b/content/aboutPage.json deleted file mode 100644 index cb3788c..0000000 --- a/content/aboutPage.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "eyebrow": "درباره قبیله", - "metaTitle": "درباره قبیله | جایی برای تجربه‌های حضوری و جمع‌های تازه", - "metaDescription": "قبیله جایی برای پیدا کردن و تجربه کردن رویدادهای حضوری و ساختن جمع‌های تازه است؛ از کشف و رزرو یک تجربه تا میزبانی و جمع کردن آدم‌ها دور چیزی که دوست دارید.", - "hero": { - "title": "ما هنوز هم دور چیزهایی که", - "titleHighlight": "دوست داریم", - "titleSuffix": "جمع می‌شیم.", - "lead": [ - "فقط شکلش عوض شده.", - "یه روز دور آتیش، امروز دور یه میز، یه بازی، یه کارگاه، یه اجرا یا هر چیزی که ارزش بیرون اومدن و با هم تجربه کردن داشته باشه.", - "قبیله برای همین جمع‌ها ساخته شده." - ] - }, - "story": { - "eyebrow": "غریزه قدیمی، زندگی امروز", - "title": "هزاران ساله", - "titleHighlight": "همین کارو می‌کنیم.", - "lead": "آدم‌ها همیشه دنبال آدم‌های خودشون بودن؛ آدم‌هایی که یه چیز مشترک دارن. یه علاقه، یه کنجکاوی، یه مهارت، یه سلیقه یا حتی فقط حال‌وهوای یه شب.", - "items": [ - { - "label": "اون موقع", - "title": "دور آتیش", - "description": "یه نقطه برای جمع شدن، حرف زدن، تجربه کردن و ساختن چیزی مشترک." - }, - { - "label": "امروز", - "title": "دور یه میز، یه بازی، یه اجرا", - "description": "بهونه‌ها عوض شدن، ولی نیاز به جمع هنوز همونه." - }, - { - "label": "مشکل امروز", - "title": "پیدا کردن این جمع‌ها همیشه راحت نیست", - "description": "خیلی از تجربه‌ها بین چند صفحه، پیام، لینک پرداخت و گروه پراکنده می‌شن. خیلی‌ها هم اصلاً نمی‌فهمن دوروبرشون چه خبره." - } - ], - "quote": "قبیله می‌خواد این فاصله رو کمتر کنه." - }, - "whatIs": { - "eyebrow": "قبیله دقیقاً چیه؟", - "title": "جایی برای پیدا کردن،", - "titleHighlight": "رفتن و جمع شدن.", - "lead": "قبیله جاییه برای پیدا کردن و تجربه کردن رویدادهای حضوری. می‌تونی ببینی این دور و برا چه خبره، یه تجربه تازه پیدا کنی، جات رو توی یه جمع نگه داری و اگه چیزی برای ساختن داری، خودت میزبان بشی.", - "items": [ - { "title": "کشف کن", "subtitle": "ببین این دور و برا چه خبره." }, - { "title": "جاتو نگه دار", "subtitle": "رزرو و پرداخت یک‌جا." }, - { "title": "آدم‌ها رو پیدا کن", "subtitle": "میزبان‌ها، نظرها و تجربه‌های مشترک." }, - { "title": "میزبان شو", "subtitle": "یه تجربه بساز و جمعش رو راه بنداز." } - ] - }, - "excuse": { - "eyebrow": "بهونه‌های خوب", - "title": "هر جمعی", - "titleHighlight": "یه بهونه می‌خواد.", - "reasons": [ - { - "icon": "workshop", - "title": "یه کارگاه", - "description": "یه چیزی یاد بگیری و آدم‌هایی رو ببینی که همون کنجکاوی رو دارن." - }, - { - "icon": "game", - "title": "یه بازی", - "description": "یه شب معمولی رو به یه خاطره مشترک تبدیل کنی." - }, - { - "icon": "show", - "title": "یه اجرا", - "description": "چیزی رو ببینی که شاید توی مسیر معمول روزمره بهش نمی‌رسیدی." - }, - { - "icon": "experience", - "title": "یه تجربه تازه", - "description": "چیزی که هنوز امتحانش نکردی و شاید بخوای دوباره برگردی سراغش." - } - ], - "quote": "برای ما خود «رویداد» آخر داستان نیست؛ فقط بهونه‌ایه که چند نفر برای چند ساعت از مسیرهای جدا بیان و یه تجربه رو با هم زندگی کنن.", - "closing": "بعضی از این جمع‌ها همون شب تموم می‌شن. بعضی‌ها تبدیل به یه خاطره می‌شن و بعضی‌ها باعث می‌شن آدم‌هایی رو پیدا کنی که دلت بخواد دوباره ببینیشون." - }, - "paths": { - "eyebrow": "دو مسیر، یک جمع", - "title": "یه وقت می‌خوای بری.", - "titleHighlight": "یه وقت می‌خوای بسازی.", - "cards": [ - { - "variant": "guest", - "kicker": "برای کسی که می‌خواد بره", - "title": "همه تجربه‌های خوب از مدت‌ها قبل برنامه‌ریزی نمی‌شن.", - "description": "گاهی چهارشنبه‌ست و فقط می‌خوای ببینی آخر هفته چه خبره. گاهی مدت‌هاست دلت می‌خواد چیزی رو امتحان کنی ولی کسی رو پیدا نکردی که باهات بیاد.", - "scenarioPrefix": "گاهی فقط یه رویداد می‌بینی و با خودت می‌گی:", - "scenarioHighlight": "«این بار برم.»" - }, - { - "variant": "host", - "kicker": "برای کسی که می‌خواد یه جمع بسازه", - "title": "پشت هر تجربه خوب، یه نفر تصمیم گرفته چیزی رو راه بندازه.", - "description": "قبیله کمک می‌کنه ثبت‌نام‌ها بین پیام‌ها گم نشن، پرداخت‌ها یک‌جا بمونن و ظرفیت، مهمان‌ها و فهرست انتظار قابل مدیریت باشن.", - "scenarioPrefix": "ما به این آدم می‌گیم:", - "scenarioHighlight": "میزبان." - } - ] - }, - "vision": { - "eyebrow": "چیزی که می‌خوایم بسازیم", - "statement": "چیزی که کم داریم، جاهایی برای پیدا کردن آدم‌ها و تجربه‌هاییه که شاید توی مسیر معمول زندگی بهشون نمی‌رسیدیم.", - "items": [ - "یه تصمیم ساده تبدیل به یه شب متفاوت بشه.", - "یه علاقه شخصی تبدیل به یه تجربه مشترک بشه.", - "یه میزبان آدم‌های درست رو دور چیزی که ساخته جمع کنه.", - "یه تجربه خوب، ردپایی برای جمع بعدی بذاره." - ] - }, - "values": { - "eyebrow": "چیزهایی که برای ما مهمه", - "title": "چند اصل ساده", - "titleHighlight": "برای یه جمع بهتر.", - "items": [ - { - "number": "۱", - "title": "آدم‌ها قبل از عددها", - "description": "رشد وقتی ارزش داره که تجربه آدم‌هایی که وارد این جمع می‌شن خراب نشه." - }, - { - "number": "۲", - "title": "شفاف بودن قبل از خرید", - "description": "باید بدونی کجا می‌ری، برای چی می‌ری، چه چیزی منتظرته و چقدر پرداخت می‌کنی." - }, - { - "number": "۳", - "title": "اعتماد دوطرفه", - "description": "هم مهمان باید بدونه پشت یه رویداد چه کسیه، هم میزبان باید بدونه چه کسانی ثبت‌نام کردن." - }, - { - "number": "۴", - "title": "تجربه در دنیای واقعی", - "description": "قبیله روی صفحه شروع می‌شه، اما چیزی که برای ما مهمه بیرون از صفحه اتفاق می‌افته." - }, - { - "number": "۵", - "title": "جمع، نه فقط برنامه", - "description": "گاهی چیزی که از یه رویداد با خودت می‌بری فقط چیزی نیست که یاد گرفتی یا دیدی؛ آدم‌هاییه که اونجا پیدا کردی." - } - ] - }, - "final": { - "title": "شاید جمع بعدی", - "titleHighlight": "همین دور و برا باشه.", - "ctaLabel": "ببین این دور و برا چه خبره", - "ctaHref": "/category" - }, - "stickyCta": { - "discoverLabel": "چه خبره؟", - "discoverHref": "/category", - "hostLabel": "یه جمع راه بنداز", - "hostHref": "/become-a-host" - }, - "footer": "قبیله · روایت مدرن غریزه قدیمی دور هم جمع شدن." -} diff --git a/content/aboutPage.ts b/content/aboutPage.ts deleted file mode 100644 index 23c7ca0..0000000 --- a/content/aboutPage.ts +++ /dev/null @@ -1,102 +0,0 @@ -import aboutPageData from '@/content/aboutPage.json' - -export type AboutReasonIcon = 'workshop' | 'game' | 'show' | 'experience' - -export interface AboutStoryItem { - label: string - title: string - description: string -} - -export interface AboutWhatItem { - title: string - subtitle: string -} - -export interface AboutReason { - icon: AboutReasonIcon - title: string - description: string -} - -export interface AboutPathCard { - variant: 'guest' | 'host' - kicker: string - title: string - description: string - scenarioPrefix: string - scenarioHighlight: string -} - -export interface AboutValue { - number: string - title: string - description: string -} - -export interface AboutPage { - eyebrow: string - metaTitle: string - metaDescription: string - hero: { - title: string - titleHighlight: string - titleSuffix: string - lead: string[] - } - story: { - eyebrow: string - title: string - titleHighlight: string - lead: string - items: AboutStoryItem[] - quote: string - } - whatIs: { - eyebrow: string - title: string - titleHighlight: string - lead: string - items: AboutWhatItem[] - } - excuse: { - eyebrow: string - title: string - titleHighlight: string - reasons: AboutReason[] - quote: string - closing: string - } - paths: { - eyebrow: string - title: string - titleHighlight: string - cards: AboutPathCard[] - } - vision: { - eyebrow: string - statement: string - items: string[] - } - values: { - eyebrow: string - title: string - titleHighlight: string - items: AboutValue[] - } - final: { - title: string - titleHighlight: string - ctaLabel: string - ctaHref: string - } - stickyCta: { - discoverLabel: string - discoverHref: string - hostLabel: string - hostHref: string - } - footer: string -} - -export const aboutPage = aboutPageData as AboutPage diff --git a/content/becomeHostPage.json b/content/becomeHostPage.json deleted file mode 100644 index 38cebe5..0000000 --- a/content/becomeHostPage.json +++ /dev/null @@ -1,234 +0,0 @@ -{ - "eyebrow": "برگزارکننده شو", - "metaTitle": "برگزارکننده شو | قبیله", - "metaDescription": "رویدادت را در قبیله بساز، ثبت‌نام، پرداخت، ظرفیت، فهرست انتظار و مهمان‌ها را یک‌جا مدیریت کن.", - "hero": { - "title": "یه رویداد می‌خوای بسازی؟", - "lead": [ - "یه کارگاه، یه بازی، یه اجرا، یه سفر، یه دورهمی یا هر چیزی که فکر می‌کنی بهتره با چند نفر اتفاق بیفته.", - "تو تجربه رو بساز. قبیله ثبت‌نام، پرداخت و مهمان‌ها رو جمع‌وجور می‌کنه." - ], - "ctaLabel": "اولین رویدادم رو می‌سازم", - "ctaHref": "#start", - "ctaNote": "احراز هویت کن، رویدادت رو بساز و وقتی آماده شد منتشرش کن.", - "stats": [ - { "value": "۲۴", "label": "ثبت‌نام قطعی" }, - { "value": "۸", "label": "نفر در انتظار" }, - { "value": "۲۴", "label": "نفر حاضر" } - ], - "benefits": [ - { "icon": "dollar", "title": "۷٪ کارمزد", "subtitle": "از فروش موفق" }, - { "icon": "payment", "title": "رزرو و پرداخت", "subtitle": "یک‌جا" }, - { "icon": "users", "title": "ظرفیت و مهمان‌ها", "subtitle": "مرتب و قابل پیگیری" }, - { "icon": "bell", "title": "یادآوری مداوم", "subtitle": "قبل از شروع" } - ] - }, - "pain": { - "eyebrow": "قبل از شروع", - "title": "یه رویداد خوب،", - "titleHighlight": "قبل از شروعش کلی کار داره", - "bubbles": [ - "هنوز جا دارید؟", - "پول رو کجا واریز کنم؟", - "ثبت‌نامم قطعی شده؟", - "آدرس دقیق کجاست؟", - "من پول دادم، اسمم هست؟", - "اگه یکی نیاد چی؟" - ], - "closing": "قرار نیست انرژیت صرف سوال‌های تکراری بشه" - }, - "attendance": { - "eyebrow": "حضور مهمان‌ها", - "title": "ثبت‌نام کرده.", - "titleHighlight": "ولی روز برنامه واقعاً میاد؟", - "lead": "پر شدن ظرفیت همیشه یعنی پر شدن صندلی‌ها نیست. گاهی مهمان چند روز قبل ثبت‌نام کرده و روز برنامه یادش می‌ره که جایی منتظرشه.", - "emphasis": "قبیله قبل از رویداد یادش می‌اندازه.", - "notification": { - "title": "فردا می‌بینیمت", - "body": "رویداد «شب بازی» فردا ساعت ۱۹ شروع می‌شه. زمان و جزئیات رویدادت رو یه بار دیگه ببین.", - "meta": "قبیله · همین حالا" - }, - "punches": ["کمتر «یادم رفت»", "کمتر جای خالی"] - }, - "timeline": { - "eyebrow": "مسیر یک رویداد", - "title": "از اولین ثبت‌نام", - "titleHighlight": "تا آخرین مهمان", - "lead": "قبیله بخش‌های تکراری مسیر رو مرتب نگه می‌داره تا تو روی خود تجربه تمرکز کنی.", - "items": [ - { - "label": "قبل از فروش", - "title": "رویدادت یه جای مشخص داره", - "description": "زمان، مکان، ظرفیت، قیمت و همه چیزهایی که مهمان باید بدونه یک‌جاست.", - "mockTitle": "صفحه رویداد", - "mockBadge": "منتشرشده", - "mockType": "lines" - }, - { - "label": "وقتی ثبت‌نام‌ها شروع می‌شن", - "title": "رزرو و پرداخت کنار همن", - "description": "می‌دونی کی قطعی شده، کی هنوز پرداخت نکرده و وضعیت هر رزرو چیه.", - "mockTitle": "رزروها", - "mockBadge": "۲۴ نفر", - "mockType": "avatars" - }, - { - "label": "وقتی ظرفیت پر می‌شه", - "title": "اگه جا باز شد، هدر نمی‌ره", - "description": "وقتی ظرفیت دوباره آزاد بشه، نفر بعدی می‌تونه فرصت ثبت‌نام پیدا کنه.", - "mockTitle": "فهرست انتظار", - "mockBadge": "۳ نفر", - "mockType": "lines" - }, - { - "label": "روز رویداد", - "title": "ببین کی واقعاً اومده", - "description": "فهرست مهمان‌ها جلوی چشمته و حضور مهمان‌های قطعی رو ثبت می‌کنی.", - "mockTitle": "حضور", - "mockBadge": "۱۸ از ۲۴", - "mockType": "lines" - } - ] - }, - "features": { - "eyebrow": "جزئیات مهم", - "title": "چیزهایی که وسط کار", - "titleHighlight": "خیلی به درد می‌خورن", - "items": [ - { - "title": "آدرس برای همه نیست", - "description": "می‌تونی آدرس دقیق رو فقط به کسایی نشون بدی که رزرو قطعی دارن.", - "visualType": "location" - }, - { - "title": "یه جمع خاص؟ یه کد مخصوص", - "description": "برای رویدادهای پولی کد تخفیف درصدی یا مبلغ ثابت بساز و محدودیتش رو خودت تعیین کن.", - "visualType": "coupon" - }, - { - "title": "می‌تونم رویدادم رو تغییر بدم؟", - "description": "تنظیمات غیر حساس رو می‌تونی تغییر بدی، تا حد امکان موقع ثبت و تأیید نهایی دقت کن.", - "visualType": "change" - } - ] - }, - "dashboard": { - "eyebrow": "میز کار برگزارکننده", - "title": "یه رویداد.", - "titleHighlight": "یه میز کار.", - "lead": "برای فهمیدن اینکه چه خبره لازم نیست پنج جا رو باز کنی.", - "summaryTitle": "شب بازی · خلاصه", - "summaryBadge": "منتشرشده", - "stats": [ - { "value": "۲۴", "label": "ثبت‌نام قطعی" }, - { "value": "۱۸", "label": "حاضر" }, - { "value": "۳", "label": "در انتظار" }, - { "value": "۹.۳م", "label": "بعد از کارمزد" } - ], - "nav": ["مهمان‌ها", "انتظار", "مالی"], - "footnote": "ثبت‌نام، حضور، فهرست انتظار، تخفیف، نظرها و وضعیت مالی، همه کنار همن.", - "ctaLabel": "رویدادم رو می‌سازم", - "ctaHref": "#start" - }, - "pricing": { - "eyebrow": "هزینه قبیله", - "title": "عددش از اول", - "titleHighlight": "روشنه.", - "percent": "۷", - "subtitle": "از فروش موفق رویداد", - "points": [ - { "title": "ساخت رویداد", "subtitle": "رایگان" }, - { "title": "فروش موفق", "subtitle": "۷٪ کارمزد" }, - { "title": "فروش نداشتی", "subtitle": "چیزی کم نمی‌شه" } - ] - }, - "steps": { - "eyebrow": "شروع کار", - "title": "چهار قدم", - "titleHighlight": "تا اولین رویداد", - "items": [ - { "title": "خودت رو معرفی کن", "description": "احراز هویتت رو کامل کن تا امکان میزبانی فعال بشه." }, - { "title": "رویدادت رو بساز", "description": "موضوع، زمان، مکان، قیمت و ظرفیت رو مشخص کن." }, - { "title": "منتشرش کن", "description": "صفحه رویدادت آماده می‌شه و می‌تونی لینکش رو هرجا جمعت هست بفرستی." }, - { "title": "میزبان باش", "description": "قبیله ثبت‌نام‌ها رو مرتب نگه می‌داره؛ تو تجربه رو بساز." } - ] - }, - "guide": { - "eyebrow": "راهنمای کوتاه", - "title": "اولین باره", - "titleHighlight": "رویداد می‌سازی؟", - "lead": "قبل از انتشار، این چندتا چیز رو روشن کن.", - "items": [ - { - "question": "آدم‌ها برای چی میان؟", - "answer": "تو یه جمله بتونی جواب بدی. آخر این برنامه قراره چی تجربه کنن، چی یاد بگیرن، چی ببینن یا چه حسی با خودشون ببرن؟ لازم نیست عجیب بنویسی؛ فقط واضح باش." - }, - { - "question": "رویدادت برای چه کسیه؟", - "answer": "اگه سطح خاص، محدودیت سنی یا وسیله لازم داره قبل از خرید بگو. هر چیزی که ممکنه مهمان بعداً بگه «کاش قبلش می‌دونستم»، باید قبلش گفته بشه." - }, - { - "question": "ظرفیت واقعی چقدره؟", - "answer": "ظرفیت فقط تعداد صندلی‌ها نیست. یه اتاق ممکنه ۳۰ نفر جا داشته باشه، اما شاید تجربه تو با ۱۲ نفر بهتر اتفاق بیفته." - }, - { - "question": "قیمت رو چطور انتخاب کنم؟", - "answer": "هزینه مکان، مواد، تجهیزات، زمان و ارزشی که تجربه برای مهمان داره رو کنار هم ببین. خیلی ارزان بودن همیشه به معنی راحت‌تر فروختن نیست." - }, - { - "question": "روز رویداد چی رو چک کنم؟", - "answer": "کمی زودتر برس، فضا و تجهیزات رو چک کن، فهرست مهمان‌ها رو آماده داشته باش و برای چیزهایی که ممکنه خراب بشن یه راه دوم داشته باش." - } - ] - }, - "faq": { - "eyebrow": "سؤال‌های قبل از شروع", - "title": "چیزی مونده", - "titleHighlight": "که باید بدونی؟", - "items": [ - { "question": "کارمزد قبیله چقدره؟", "answer": "۷٪ از فروش موفق رویداد." }, - { - "question": "ساخت رویداد هزینه داره؟", - "answer": "برای ساخت رویداد هزینه‌ای ازت گرفته نمی‌شه. کارمزد قبیله از فروش موفق محاسبه می‌شه." - }, - { - "question": "برای میزبانی باید احراز هویت کنم؟", - "answer": "بله. قبل از فعال‌شدن امکان ساخت رویداد، احراز هویت برگزارکننده لازمه." - }, - { - "question": "اگه مهمان روز برنامه یادش بره چی؟", - "answer": "قبیله قبل از رویداد برای مهمان یادآوری می‌فرسته. این کار حضور رو تضمین نمی‌کنه، اما کمک می‌کنه «یادم رفت» کمتر اتفاق بیفته." - }, - { - "question": "اگه ظرفیت پر بشه چی؟", - "answer": "رزرو مستقیم متوقف می‌شه و مهمان‌های بعدی می‌تونن وارد فهرست انتظار بشن. اگر جایی آزاد بشه، نفر بعد فرصت ثبت‌نام پیدا می‌کنه." - }, - { - "question": "اگه رویداد رو لغو کنم چی؟", - "answer": "رزروهای رویداد لغو می‌شن و فرایند اطلاع‌رسانی و بازپرداخت طبق قوانین رویداد و قبیله انجام می‌شه." - }, - { - "question": "می‌تونم به مهمان‌ها تخفیف بدم؟", - "answer": "برای رویداد پولی می‌تونی کد تخفیف درصدی یا مبلغ ثابت بسازی و محدودیت استفاده براش تعیین کنی." - }, - { - "question": "بعد از رویداد چه اتفاقی می‌افته؟", - "answer": "مهمان‌های واجد شرایط می‌تونن نظر بدن، تو می‌تونی پاسخ بدی و سابقه رویدادها روی پروفایلت می‌مونه." - } - ] - }, - "final": { - "eyebrow": "از یه جا باید شروع بشه", - "title": "یه جمع، قبل از اینکه جمع بشه،", - "titleHighlight": "فقط یه ایده‌ست.", - "lead": "شاید الان فقط یه موضوع توی ذهنت داری؛ یه بازی، یه چیزی که بلدی یا یه تجربه که فکر می‌کنی چند نفر دیگه هم باید امتحانش کنن.", - "ctaLabel": "اولین رویدادم رو می‌سازم", - "ctaHref": "#start", - "ctaNote": "ساخت رویداد رایگانه · کارمزد فقط از فروش موفق" - }, - "stickyCta": { - "priceLabel": "۷٪ از فروش موفق", - "priceNote": "ساخت رویداد رایگانه", - "buttonLabel": "ساخت رویداد" - } -} diff --git a/content/becomeHostPage.ts b/content/becomeHostPage.ts deleted file mode 100644 index d393c42..0000000 --- a/content/becomeHostPage.ts +++ /dev/null @@ -1,139 +0,0 @@ -import becomeHostPageData from '@/content/becomeHostPage.json' - -export type BecomeHostBenefitIcon = 'dollar' | 'payment' | 'users' | 'bell' - -export interface BecomeHostStat { - value: string - label: string -} - -export interface BecomeHostBenefit { - icon: BecomeHostBenefitIcon - title: string - subtitle: string -} - -export interface BecomeHostTimelineItem { - label: string - title: string - description: string - mockTitle: string - mockBadge: string - mockType: 'lines' | 'avatars' -} - -export interface BecomeHostFeatureItem { - title: string - description: string - visualType: 'location' | 'coupon' | 'change' -} - -export interface BecomeHostFaqItem { - question: string - answer: string -} - -export interface BecomeHostPricingPoint { - title: string - subtitle: string -} - -export interface BecomeHostPage { - eyebrow: string - metaTitle: string - metaDescription: string - hero: { - title: string - lead: string[] - ctaLabel: string - ctaHref: string - ctaNote: string - stats: BecomeHostStat[] - benefits: BecomeHostBenefit[] - } - pain: { - eyebrow: string - title: string - titleHighlight: string - bubbles: string[] - closing: string - } - attendance: { - eyebrow: string - title: string - titleHighlight: string - lead: string - emphasis: string - notification: { title: string; body: string; meta: string } - punches: [string, string] - } - timeline: { - eyebrow: string - title: string - titleHighlight: string - lead: string - items: BecomeHostTimelineItem[] - } - features: { - eyebrow: string - title: string - titleHighlight: string - items: BecomeHostFeatureItem[] - } - dashboard: { - eyebrow: string - title: string - titleHighlight: string - lead: string - summaryTitle: string - summaryBadge: string - stats: BecomeHostStat[] - nav: string[] - footnote: string - ctaLabel: string - ctaHref: string - } - pricing: { - eyebrow: string - title: string - titleHighlight: string - percent: string - subtitle: string - points: BecomeHostPricingPoint[] - } - steps: { - eyebrow: string - title: string - titleHighlight: string - items: { title: string; description: string }[] - } - guide: { - eyebrow: string - title: string - titleHighlight: string - lead: string - items: BecomeHostFaqItem[] - } - faq: { - eyebrow: string - title: string - titleHighlight: string - items: BecomeHostFaqItem[] - } - final: { - eyebrow: string - title: string - titleHighlight: string - lead: string - ctaLabel: string - ctaHref: string - ctaNote: string - } - stickyCta: { - priceLabel: string - priceNote: string - buttonLabel: string - } -} - -export const becomeHostPage = becomeHostPageData as BecomeHostPage diff --git a/content/host-cooperation-contract.json b/content/host-cooperation-contract.json deleted file mode 100644 index 9903f31..0000000 --- a/content/host-cooperation-contract.json +++ /dev/null @@ -1,828 +0,0 @@ -{ - "version": "2026-08-22", - "id": "host-cooperation-b2b", - "title": "قرارداد الکترونیکی حق‌العمل‌کاری فروش بلیط و همکاری برگزارکنندگان با قبیله", - "preamble": "این قرارداد بر اساس ماده ۱۰ و سایر مقررات قانون مدنی، مواد ۳۵۷ تا ۳۷۶ قانون تجارت، قانون تجارت الکترونیکی، قانون حمایت از حقوق مصرف‌کنندگان و آیین‌نامه‌های مربوط و سایر قوانین و مقررات لازم‌الاجرا، میان طرفین زیر منعقد می‌شود.", - "articles": [ - { - "number": 1, - "heading": "ماده ۱ ـ طرفین قرارداد", - "blocks": [ - { - "type": "clause", - "text": "۱ـ۱. قبیله: شخص حقوقی بنیان توسعه همگرا، بهره‌بردار سکوی قبیله، به شناسه ملی 14015572022، شماره ثبت 98010، نشانی خراسان رضوی مشهد بلوار هاشمیه هاشمیه 28.8 پلاک 62 طبقه همکف، که از این پس در این قرارداد «قبیله» یا حسب مورد «حق‌العمل‌کار» نامیده می‌شود." - }, - { - "type": "clause", - "text": "۱ـ۲. برگزارکننده: شخص حقیقی یا حقوقی که با ایجاد حساب برگزارکننده و تکمیل اطلاعات هویتی، بانکی، مالیاتی و تماس خود در قبیله، این قرارداد را به‌صورت الکترونیکی می‌پذیرد و از این پس «برگزارکننده» یا «آمر» نامیده می‌شود." - }, - { - "type": "paragraph", - "text": "کلیه اطلاعات ثبت‌شده و تأییدشده برگزارکننده در حساب کاربری، از جمله نام و نام خانوادگی، کد ملی، نشانی، شماره تماس، حساب بانکی و مشخصات نماینده مجاز، جزء لاینفک این قرارداد است." - } - ] - }, - { - "number": 2, - "heading": "ماده ۲ ـ تعاریف", - "blocks": [ - { - "type": "paragraph", - "text": "در این قرارداد اصطلاحات زیر در معانی مذکور به کار می‌روند:" - }, - { - "type": "paragraph", - "text": "قبیله: سکوی برخطی که امکان ایجاد و معرفی رویداد، ثبت‌نام شرکت‌کنندگان، فروش یا رزرو بلیط، وصول وجوه، مدیریت اطلاعات شرکت‌کنندگان و تسویه با برگزارکننده را فراهم می‌کند." - }, - { - "type": "paragraph", - "text": "رویداد: هر برنامه، گردهمایی، کارگاه، کلاس، اجرا، جشنواره، تجربه، مسابقه، دورهمی یا فعالیت دیگری که مسئولیت ایجاد، اجرا و ارائه آن بر عهده برگزارکننده است." - }, - { - "type": "paragraph", - "text": "شرکت‌کننده: شخصی که از طریق قبیله برای رویداد ثبت‌نام می‌کند یا بلیط خریداری می‌کند." - }, - { - "type": "paragraph", - "text": "بلیط: سند یا تأییدیه الکترونیکی ثبت‌نام یا حق حضور در رویداد برگزارکننده." - }, - { - "type": "paragraph", - "text": "وجوه رویداد: مجموع مبالغی که بابت بلیط، ثبت‌نام یا سایر خدمات مربوط به رویداد از شرکت‌کنندگان وصول می‌شود." - }, - { - "type": "paragraph", - "text": "حق‌العمل قبیله: مبلغ ثابت، درصدی از فروش یا ترکیبی از آن دو که مطابق شرایط مالی نمایش‌داده‌شده در پنل برگزارکننده برای استفاده از خدمات قبیله دریافت می‌شود." - }, - { - "type": "paragraph", - "text": "تسویه: انتقال مانده وجوه متعلق به برگزارکننده پس از کسر حق‌العمل قبیله، مالیات و عوارض متعلق به خدمات قبیله، مبالغ قابل استرداد، هزینه‌های قانونی، کسورات قراردادی و سایر مطالبات قابل کسر." - } - ] - }, - { - "number": 3, - "heading": "ماده ۳ ـ موضوع قرارداد", - "blocks": [ - { - "type": "paragraph", - "text": "موضوع قرارداد عبارت است از:" - }, - { - "type": "numbered_item", - "text": "۱. ارائه زیرساخت برخط برای ایجاد، انتشار و مدیریت رویدادهای برگزارکننده؛" - }, - { - "type": "numbered_item", - "text": "۲. معرفی و نمایش اطلاعات رویداد؛" - }, - { - "type": "numbered_item", - "text": "۳. فراهم‌کردن امکان ثبت‌نام، رزرو یا خرید بلیط توسط شرکت‌کنندگان؛" - }, - { - "type": "numbered_item", - "text": "۴. انجام عملیات فروش و وصول وجه به حساب برگزارکننده در قالب رابطه حق‌العمل‌کاری و واسطه‌گری؛" - }, - { - "type": "numbered_item", - "text": "۵. دریافت وجوه بلیط از طریق درگاه‌های پرداخت متصل به قبیله؛" - }, - { - "type": "numbered_item", - "text": "۶. نگهداری حساب و گزارش فروش و انجام تسویه با برگزارکننده؛" - }, - { - "type": "numbered_item", - "text": "۷. ارائه سایر خدمات فنی، اطلاع‌رسانی و پشتیبانی مندرج در سکو." - }, - { - "type": "paragraph", - "text": "برگزارکننده به قبیله اختیار می‌دهد در حدود این قرارداد، نسبت به فروش بلیط، وصول وجوه، ثبت تراکنش‌ها، انجام استردادهای مجاز، صدور یا انتقال اطلاعات لازم برای صورتحساب و تسویه وجوه اقدام کند." - }, - { - "type": "paragraph", - "text": "طرفین تصریح می‌کنند حق‌العمل‌کاری قبیله ناظر به فرایند فروش، وصول و تسویه است و به هیچ عنوان موجب انتقال مسئولیت ایجاد، اجرا، کیفیت، ایمنی یا قانونی بودن رویداد از برگزارکننده به قبیله نمی‌شود." - } - ] - }, - { - "number": 4, - "heading": "ماده ۴ ـ ماهیت رابطه طرفین", - "blocks": [ - { - "type": "clause", - "text": "۴ـ۱. برگزارکننده، آمر و ارائه‌دهنده اصلی خدمت موضوع رویداد است." - }, - { - "type": "clause", - "text": "۴ـ۲. قبیله برگزارکننده، تهیه‌کننده، مالک، مدیر محل، شریک تجاری، کارفرما یا ضامن اجرای رویداد محسوب نمی‌شود، مگر اینکه درباره یک رویداد مشخص قرارداد مکتوب دیگری صراحتاً خلاف آن را مقرر کرده باشد." - } - ] - }, - { - "number": 5, - "heading": "ماده ۵ ـ مالکیت و ماهیت وجوه دریافتی", - "blocks": [ - { - "type": "clause", - "text": "۵ـ۱. برگزارکننده صراحتاً به قبیله اختیار می‌دهد وجوه پرداختی شرکت‌کنندگان بابت بلیط رویداد را از طریق درگاه یا حساب‌های معرفی‌شده توسط قبیله وصول کند." - }, - { - "type": "clause", - "text": "۵ـ۲. ورود تمام یا بخشی از وجه بلیط به حساب بانکی یا درگاه متعلق به قبیله صرفاً ناشی از سازوکار وصول و تسویه بوده و طرفین توافق دارند، جز در خصوص حق‌العمل، مالیات و عوارض خدمات قبیله و سایر مطالبات قانونی یا قراردادی قبیله، وجوه مزبور به حساب برگزارکننده وصول می‌شود." - }, - { - "type": "clause", - "text": "۵ـ۳. طرفین تصریح می‌کنند مالکیت اقتصادی درآمد ناشی از ارائه اصل خدمت رویداد متعلق به برگزارکننده است و حق قبیله نسبت به وجوه وصولی محدود به حق‌العمل و مطالبات قانونی و قراردادی آن است." - } - ] - }, - { - "number": 6, - "heading": "ماده ۶ ـ حق‌العمل و سایر هزینه‌ها", - "blocks": [ - { - "type": "clause", - "text": "۶ـ۱. میزان حق‌العمل قبیله برای هر رویداد مطابق درصد یا مبلغی است که هنگام ایجاد یا انتشار رویداد در پنل برگزارکننده اعلام و توسط برگزارکننده تأیید شده است." - }, - { - "type": "clause", - "text": "۶ـ2. برگزارکننده اجازه می‌دهد قبیله پیش از تسویه، موارد زیر را از وجوه وصولی کسر کند:" - }, - { - "type": "paragraph", - "text": "الف) حق‌العمل قبیله؛" - }, - { - "type": "paragraph", - "text": "ب) وجوه قابل استرداد به شرکت‌کنندگان؛" - } - ] - }, - { - "number": 7, - "heading": "ماده ۷ ـ تسویه با برگزارکننده", - "blocks": [ - { - "type": "clause", - "text": "۷ـ۱. زمان و شرایط تسویه هر رویداد مطابق اطلاعاتی است که پیش از انتشار یا فروش در پنل برگزارکننده نمایش داده می‌شود." - }, - { - "type": "clause", - "text": "۷ـ2. تسویه فقط به حساب بانکی متعلق به برگزارکننده انجام می‌شود." - }, - { - "type": "clause", - "text": "۷ـ3. چنانچه پس از تسویه، برگزارکننده بابت استرداد وجه، شکایت شرکت‌کننده، مالیات، خسارت یا سایر تعهدات موضوع این قرارداد بدهکار شود، قبیله حق دارد مبلغ مربوط را از تسویه سایر رویدادهای برگزارکننده تهاتر کند." - } - ] - }, - { - "number": 8, - "heading": "ماده ۸ ـ تعهدات مالیاتی و سامانه مؤدیان", - "blocks": [ - { - "type": "clause", - "text": "۸ـ۱. هر طرف مسئول انجام تکالیف مالیاتی مربوط به درآمد و خدمات متعلق به خود است." - }, - { - "type": "clause", - "text": "۸ـ2. قبیله مسئول تکالیف مالیاتی مربوط به حق‌العمل و سایر خدمات مستقلی است که خود ارائه می‌کند." - }, - { - "type": "clause", - "text": "۸ـ3. در صورت لزوم ثبت رابطه حق‌العمل‌کاری در سامانه مؤدیان، برگزارکننده متعهد است قرارداد را در کارپوشه مالیاتی خود تأیید کند و همکاری لازم برای دریافت و استفاده از شناسه یکتای قرارداد حق‌العمل‌کاری را انجام دهد." - } - ] - }, - { - "number": 9, - "heading": "ماده ۹ ـ مسئولیت قانونی برگزارکننده نسبت به رویداد", - "blocks": [ - { - "type": "paragraph", - "text": "برگزارکننده اقرار و تعهد می‌کند که مسئولیت کامل امور زیر با اوست:" - }, - { - "type": "numbered_item", - "text": "۱. قانونی بودن موضوع و نحوه اجرای رویداد؛" - }, - { - "type": "numbered_item", - "text": "۲. اخذ و حفظ تمام مجوزها، موافقت‌ها و تأییدیه‌های لازم؛" - }, - { - "type": "numbered_item", - "text": "۳. قانونی و مناسب بودن محل برگزاری؛" - }, - { - "type": "numbered_item", - "text": "۴. رعایت ظرفیت مجاز محل و الزامات ایمنی؛" - }, - { - "type": "numbered_item", - "text": "۵. تأمین نیرو، تجهیزات، عوامل اجرایی و پیمانکاران مورد نیاز؛" - }, - { - "type": "numbered_item", - "text": "۶. رعایت مقررات صنفی، فرهنگی، انتظامی، بهداشتی، ورزشی، ایمنی و سایر ضوابط مرتبط حسب نوع رویداد؛" - }, - { - "type": "numbered_item", - "text": "۷. صحت اطلاعات ارائه‌شده درباره برنامه، زمان، مکان، عوامل، امکانات و ویژگی‌های رویداد؛" - }, - { - "type": "numbered_item", - "text": "۸. اجرای صحیح و کامل تعهدات وعده‌داده‌شده به شرکت‌کنندگان؛" - }, - { - "type": "numbered_item", - "text": "۹. رفتار و عملکرد کارکنان، عوامل، مدرسین، اجراکنندگان، پیمانکاران، محل برگزاری و اشخاصی که به دعوت یا دستور برگزارکننده در رویداد فعالیت می‌کنند؛" - }, - { - "type": "numbered_item", - "text": "۱۰. جبران خسارات جانی یا مالی ناشی از اجرای رویداد در حدودی که قانوناً منتسب به برگزارکننده یا عوامل او باشد." - }, - { - "type": "paragraph", - "text": "عدم مطالبه یا بررسی این اسناد از سوی قبیله به معنای تأیید قانونی بودن رویداد یا قبول مسئولیت آن توسط قبیله نیست." - } - ] - }, - { - "number": 10, - "heading": "ماده ۱۰ ـ اطلاعات رویداد", - "blocks": [ - { - "type": "clause", - "text": "۱۰ـ۱. برگزارکننده موظف است پیش از انتشار رویداد، اطلاعات کامل و صحیح از جمله موارد زیر را اعلام کند:" - }, - { - "type": "paragraph", - "text": "عنوان و موضوع رویداد، زمان آغاز و پایان، نشانی محل، قیمت، ظرفیت، شرایط حضور، محدودیت‌های سنی، امکانات و خدمات، شرایط لغو و استرداد، نام برگزارکننده و هر اطلاعاتی که عرفاً در تصمیم شرکت‌کننده مؤثر است." - }, - { - "type": "clause", - "text": "۱۰ـ۲. برگزارکننده مسئول هرگونه ادعای خلاف واقع، اطلاعات ناقص یا گمراه‌کننده است." - }, - { - "type": "clause", - "text": "۱۰ـ۳. هرگونه تغییر مؤثر در زمان، مکان، برنامه، عوامل اصلی، خدمات وعده‌داده‌شده یا شرایط حضور باید فوراً به قبیله اعلام شود." - }, - { - "type": "clause", - "text": "۱۰ـ۴. قبیله می‌تواند انتشار رویدادی را که اطلاعات آن ناقص، مشکوک، خلاف قانون یا مغایر با ضوابط سکو باشد متوقف کند یا توضیحات و مدارک تکمیلی مطالبه کند." - } - ] - }, - { - "number": 11, - "heading": "ماده ۱۱ ـ قیمت‌گذاری و ظرفیت", - "blocks": [ - { - "type": "clause", - "text": "۱۱ـ۱. تعیین قیمت بلیط و ظرفیت رویداد بر عهده برگزارکننده است، مگر در مواردی که طرفین به نحو دیگری توافق کرده باشند." - }, - { - "type": "clause", - "text": "۱۱ـ2. برگزارکننده حق فروش بیش از ظرفیت واقعی و مجاز رویداد را ندارد." - }, - { - "type": "clause", - "text": "۱۱ـ3. اگر به علت اطلاعات یا ظرفیت نادرست اعلام‌شده از سوی برگزارکننده، قبیله ناچار به استرداد وجه یا جبران خسارت شود، قبیله حق رجوع به برگزارکننده را خواهد داشت." - } - ] - }, - { - "number": 12, - "heading": "ماده ۱۲ ـ لغو، تعویق یا تغییر اساسی رویداد", - "blocks": [ - { - "type": "clause", - "text": "۱۲ـ۱. برگزارکننده موظف است هرگونه لغو، تعویق یا تغییر اساسی را بلافاصله از طریق پنل به قبیله اعلام کند." - }, - { - "type": "clause", - "text": "۱۲ـ۲. در صورت لغو رویداد، اصل بر استرداد وجوه مربوط به خدمات ارائه‌نشده به شرکت‌کنندگان طبق قانون و شرایط خرید است." - }, - { - "type": "clause", - "text": "۱۲ـ۳. قبیله اختیار دارد در موارد زیر بدون نیاز به اخذ اجازه مجدد از برگزارکننده، استرداد وجه را انجام دهد:" - }, - { - "type": "paragraph", - "text": "الف) لغو رویداد؛" - }, - { - "type": "paragraph", - "text": "ب) تعویق یا تغییر اساسی که طبق قانون یا شرایط خرید موجب حق استرداد باشد؛" - }, - { - "type": "letter_item", - "text": "پ) دستور مرجع قضایی، انتظامی، اداری یا قانونی؛" - }, - { - "type": "paragraph", - "text": "ت) احراز پرداخت تکراری یا اشتباه پرداخت؛" - }, - { - "type": "paragraph", - "text": "ث) ضرورت اجرای حقوق قانونی مصرف‌کننده؛" - }, - { - "type": "paragraph", - "text": "ج) وجود دلایل متعارف مبنی بر تقلب یا عدم امکان برگزاری." - }, - { - "type": "clause", - "text": "۱۲ـ۴. اگر وجه مربوط قبلاً به برگزارکننده تسویه شده باشد، برگزارکننده موظف است ظرف دو روز کاری از اعلام قبیله، مبلغ مورد نیاز برای استرداد را در اختیار قبیله قرار دهد." - }, - { - "type": "clause", - "text": "۱۲ـ۵. قبیله می‌تواند مبالغ مورد نیاز برای استرداد را از سایر مطالبات یا تسویه‌های برگزارکننده تهاتر کند." - } - ] - }, - { - "number": 13, - "heading": "ماده ۱۳ ـ حقوق شرکت‌کنندگان و استرداد", - "blocks": [ - { - "type": "clause", - "text": "۱۳ـ۱. برگزارکننده متعهد است تمام حقوق قانونی مصرف‌کنندگان و مقررات معاملات از راه دور را رعایت کند." - }, - { - "type": "clause", - "text": "۱۳ـ۲. سیاست اختصاصی هر رویداد درباره لغو یا استرداد نمی‌تواند حقوقی را که طبق قوانین آمره برای مصرف‌کننده ایجاد شده محدود یا ساقط کند." - } - ] - }, - { - "number": 14, - "heading": "ماده ۱۴ ـ شکایات شرکت‌کنندگان", - "blocks": [ - { - "type": "clause", - "text": "۱۴ـ۱. برگزارکننده، به‌عنوان ارائه‌دهنده اصلی رویداد، مسئول اولیه رسیدگی به شکایات مربوط به اجرای رویداد است." - }, - { - "type": "clause", - "text": "۱۴ـ۲. برگزارکننده موظف است درخواست‌ها و شکایات ارجاع‌شده از سوی قبیله را در مهلت اعلام‌شده و حداکثر ظرف مدت متعارف پاسخ دهد." - }, - { - "type": "clause", - "text": "۱۴ـ۳. قبیله می‌تواند برای حل اختلاف میان شرکت‌کننده و برگزارکننده، اطلاعات و مستندات لازم را مطالبه و نقش میانجی ایفا کند." - } - ] - }, - { - "number": 15, - "heading": "ماده ۱۵ ـ ایمنی و خسارات ناشی از رویداد", - "blocks": [ - { - "type": "clause", - "text": "۱۵ـ۱. مسئولیت اداره فیزیکی رویداد، مدیریت جمعیت، ایمنی محل، تجهیزات، عوامل اجرایی و اقدامات اضطراری بر عهده برگزارکننده است." - }, - { - "type": "clause", - "text": "۱۵ـ۲. برگزارکننده موظف است در صورت اقتضای ماهیت رویداد، پوشش‌های بیمه‌ای و تمهیدات ایمنی لازم را فراهم کند." - }, - { - "type": "clause", - "text": "۱۵ـ۳. مسئولیت خسارت جانی، مالی یا معنوی ناشی از فعل یا ترک فعل برگزارکننده، کارکنان، پیمانکاران، اجراکنندگان یا عوامل تحت اختیار وی بر عهده برگزارکننده است." - }, - { - "type": "clause", - "text": "۱۵ـ۴. قبیله مسئول حوادث ناشی از اجرای فیزیکی رویداد نیست، مگر آنکه خسارت مستقیماً ناشی از فعل مستقل و قابل انتساب قبیله باشد." - } - ] - }, - { - "number": 16, - "heading": "ماده ۱۶ ـ جبران خسارت و حمایت از قبیله", - "blocks": [ - { - "type": "paragraph", - "text": "برگزارکننده متعهد است در حدود قوانین لازم‌الاجرا، قبیله، مدیران و کارکنان آن را در برابر خسارات، مطالبات و هزینه‌های متعارف و مستندی که مستقیماً ناشی از موارد زیر باشد جبران کند:" - }, - { - "type": "numbered_item", - "text": "۱. لغو یا عدم اجرای رویداد؛" - }, - { - "type": "numbered_item", - "text": "۲. تخلف از شرایط اعلام‌شده رویداد؛" - }, - { - "type": "numbered_item", - "text": "۳. فقدان مجوز لازم؛" - }, - { - "type": "numbered_item", - "text": "۴. نقض مقررات مربوط به محل و ایمنی؛" - }, - { - "type": "numbered_item", - "text": "۵. صدمه جانی یا مالی ناشی از اجرای رویداد؛" - }, - { - "type": "numbered_item", - "text": "۶. ادعای خلاف واقع یا گمراه‌کننده؛" - }, - { - "type": "numbered_item", - "text": "۷. نقض حقوق مالکیت فکری اشخاص ثالث توسط برگزارکننده؛" - }, - { - "type": "numbered_item", - "text": "۸. استفاده غیرمجاز برگزارکننده از اطلاعات شرکت‌کنندگان؛" - }, - { - "type": "numbered_item", - "text": "۹. تخلفات مالیاتی یا قانونی منتسب به برگزارکننده؛" - }, - { - "type": "numbered_item", - "text": "۱۰. مطالبات کارکنان، پیمانکاران، هنرمندان، مدرسین یا سایر عوامل برگزارکننده؛" - }, - { - "type": "numbered_item", - "text": "۱۱. استردادهایی که به علت فعل، ترک فعل یا تخلف برگزارکننده ایجاد شده است." - }, - { - "type": "paragraph", - "text": "این ماده شامل خسارتی که مستقیماً ناشی از تقصیر، تخلف قانونی، نقص سامانه یا فعل مستقل قبیله باشد نخواهد بود." - } - ] - }, - { - "number": 17, - "heading": "ماده ۱۷ ـ حدود مسئولیت قبیله", - "blocks": [ - { - "type": "clause", - "text": "۱۷ـ۱. قبیله مسئول انجام تعهدات مستقیم خود در زمینه خدمات سکو، عملیات پرداخت، نگهداری اطلاعات و تسویه در حدود این قرارداد و قوانین است." - }, - { - "type": "clause", - "text": "۱۷ـ2. قبیله در قبال کیفیت محتوای رویداد، عملکرد برگزارکننده، محل، مدرس، هنرمند، سخنران، تجهیزات یا سایر عوامل اجرای رویداد تضمینی ارائه نمی‌کند." - } - ] - }, - { - "number": 18, - "heading": "ماده ۱۸ ـ اطلاعات شخصی شرکت‌کنندگان", - "blocks": [ - { - "type": "clause", - "text": "۱۸ـ۱. اطلاعات شرکت‌کنندگان صرفاً در حد لازم برای اجرای رویداد، احراز بلیط، پشتیبانی، اجرای تعهدات قانونی و اهدافی که به کاربر اعلام شده است در اختیار برگزارکننده قرار می‌گیرد." - }, - { - "type": "clause", - "text": "۱۸ـ۲. برگزارکننده حق فروش، واگذاری، انتشار یا استفاده خارج از هدف از اطلاعات شرکت‌کنندگان را ندارد." - }, - { - "type": "clause", - "text": "۱۸ـ۳. استفاده از اطلاعات شرکت‌کنندگان برای تبلیغات مستقل برگزارکننده تنها در صورتی مجاز است که مبنای قانونی و رضایت لازم وجود داشته باشد." - }, - { - "type": "clause", - "text": "۱۸ـ4. در صورت دسترسی غیرمجاز، افشا، مفقودی یا رخداد امنیتی مرتبط با اطلاعات شرکت‌کنندگان، برگزارکننده موظف است فوراً و حداکثر ظرف ۲۴ ساعت قبیله را مطلع کند و برای کنترل آثار آن همکاری نماید." - }, - { - "type": "clause", - "text": "۱۸ـ5. جمع‌آوری یا پردازش اطلاعات حساس، از جمله اطلاعات مربوط به وضعیت جسمانی، روانی یا سایر داده‌های خاص، فقط در حدود ضرورت و با رعایت رضایت و الزامات قانونی مجاز است." - }, - { - "type": "clause", - "text": "۱۸ـ6. برگزارکننده پس از رفع نیاز قانونی و عملیاتی به اطلاعات، موظف است مطابق قوانین و دستورالعمل‌های قبیله نسبت به حذف یا محدودسازی دسترسی به آن اقدام کند." - } - ] - }, - { - "number": 19, - "heading": "ماده ۱۹ ـ مالکیت فکری و محتوای رویداد", - "blocks": [ - { - "type": "clause", - "text": "۱۹ـ۱. برگزارکننده تضمین می‌کند نسبت به تمام تصاویر، نام‌ها، علائم، آثار، متن‌ها، ویدئوها و محتوایی که در قبیله منتشر می‌کند دارای حقوق یا مجوزهای لازم است." - }, - { - "type": "clause", - "text": "۱۹ـ۲. برگزارکننده به قبیله اجازه غیرانحصاری می‌دهد محتوای رویداد را برای معرفی، فروش، اطلاع‌رسانی و تبلیغ همان رویداد در سکو و رسانه‌های قبیله نمایش و بازنشر کند." - }, - { - "type": "clause", - "text": "۱۹ـ۳. این اجازه موجب انتقال مالکیت محتوای برگزارکننده به قبیله نمی‌شود." - }, - { - "type": "clause", - "text": "۱۹ـ۴. مسئولیت ادعای اشخاص ثالث درباره نقض حقوق مؤلف، علامت تجاری، تصویر، نام یا سایر حقوق ناشی از محتوای بارگذاری‌شده توسط برگزارکننده بر عهده وی است." - } - ] - }, - { - "number": 20, - "heading": "ماده ۲۰ ـ فعالیت‌های ممنوع", - "blocks": [ - { - "type": "paragraph", - "text": "برگزارکننده حق ندارد از قبیله برای موارد زیر استفاده کند:" - }, - { - "type": "numbered_item", - "text": "۱. برگزاری یا فروش خدمات غیرقانونی؛" - }, - { - "type": "numbered_item", - "text": "۲. ایجاد رویداد صوری یا دریافت وجه بدون قصد واقعی اجرا؛" - }, - { - "type": "numbered_item", - "text": "۳. ارائه اطلاعات هویتی یا بانکی نادرست؛" - }, - { - "type": "numbered_item", - "text": "۴. نقض حقوق اشخاص ثالث؛" - }, - { - "type": "numbered_item", - "text": "۵. پول‌شویی، گردش وجوه صوری یا استفاده نامرتبط از درگاه پرداخت؛" - }, - { - "type": "numbered_item", - "text": "۶. فروش بیش از ظرفیت واقعی؛" - }, - { - "type": "numbered_item", - "text": "۷. سوءاستفاده از اطلاعات کاربران؛" - }, - { - "type": "numbered_item", - "text": "۸. انتشار محتوای خلاف قوانین لازم‌الاجرا؛" - }, - { - "type": "numbered_item", - "text": "۹. دور زدن عامدانه سازوکار مالی قبیله در خصوص ثبت‌نام‌هایی که از طریق خدمات قبیله ایجاد شده‌اند، در صورتی که شرایط مالی رویداد این اقدام را ممنوع کرده باشد." - }, - { - "type": "paragraph", - "text": "در صورت مشاهده دلایل متعارف درباره هر یک از موارد فوق، قبیله حق تعلیق حساب، توقف فروش یا تسویه و درخواست توضیح یا مدرک را خواهد داشت." - } - ] - }, - { - "number": 21, - "heading": "ماده ۲۱ ـ تعلیق و توقف فعالیت", - "blocks": [ - { - "type": "paragraph", - "text": "قبیله می‌تواند در موارد زیر حساب یا رویداد برگزارکننده را موقتاً تعلیق کند:" - }, - { - "type": "numbered_item", - "text": "۱. دستور مرجع صالح؛" - }, - { - "type": "numbered_item", - "text": "۲. فقدان یا انقضای مجوز مورد نیاز؛" - }, - { - "type": "numbered_item", - "text": "۳. شکایات متعدد یا مستند؛" - }, - { - "type": "numbered_item", - "text": "۴. احتمال معقول تقلب؛" - }, - { - "type": "numbered_item", - "text": "۵. بدهی سررسیدشده به قبیله یا شرکت‌کنندگان؛" - }, - { - "type": "numbered_item", - "text": "۶. ارائه اطلاعات نادرست؛" - }, - { - "type": "numbered_item", - "text": "۷. احتمال جدی عدم برگزاری رویداد؛" - }, - { - "type": "numbered_item", - "text": "۸. نقض اساسی این قرارداد یا قوانین." - }, - { - "type": "paragraph", - "text": "تعلیق تا حد ممکن متناسب با خطر موجود خواهد بود و قبیله می‌تواند برای رفع آن از برگزارکننده تضمین، مدرک یا ذخیره مالی مطالبه کند." - } - ] - }, - { - "number": 22, - "heading": "ماده ۲۲ ـ مدت و خاتمه قرارداد", - "blocks": [ - { - "type": "clause", - "text": "۲۲ـ۱. قرارداد از زمان پذیرش الکترونیکی توسط برگزارکننده لازم‌الاجرا است و تا زمان خاتمه حساب یا فسخ مطابق این قرارداد ادامه دارد." - }, - { - "type": "clause", - "text": "۲۲ـ۲. برگزارکننده می‌تواند در صورت نداشتن رویداد فعال، بدهی، استرداد معوق یا تعهد نسبت به شرکت‌کنندگان درخواست پایان همکاری کند." - }, - { - "type": "clause", - "text": "۲۲ـ۳. خاتمه قرارداد موجب سقوط تعهدات مربوط به بلیط‌های فروخته‌شده، استردادها، بدهی‌ها، مالیات، محرمانگی، داده‌های شخصی و جبران خسارت نمی‌شود." - }, - { - "type": "clause", - "text": "۲۲ـ۴. برگزارکننده صرفاً با بستن حساب نمی‌تواند از اجرای رویدادهای فروخته‌شده یا بازپرداخت تعهدات مربوط خودداری کند." - } - ] - }, - { - "number": 23, - "heading": "ماده ۲۳ ـ حوادث خارج از اختیار", - "blocks": [ - { - "type": "paragraph", - "text": "در صورت وقوع حادثه‌ای خارج از اختیار متعارف طرفین که اجرای رویداد را غیرممکن کند، طرف متأثر موظف است در اسرع وقت طرف دیگر را مطلع و برای کاهش خسارت همکاری کند." - }, - { - "type": "paragraph", - "text": "وقوع حادثه خارج از اختیار، به‌خودی‌خود حقوق قانونی شرکت‌کنندگان نسبت به وجوه مربوط به خدماتی که ارائه نشده است را ساقط نمی‌کند." - }, - { - "type": "paragraph", - "text": "استحقاق طرفین نسبت به هزینه‌ها و خسارات ناشی از چنین وضعیتی مطابق قانون، شرایط رویداد و میزان خدمات انجام‌شده تعیین خواهد شد." - } - ] - }, - { - "number": 24, - "heading": "ماده ۲۴ ـ محرمانگی", - "blocks": [ - { - "type": "paragraph", - "text": "هر یک از طرفین متعهد است اطلاعات غیرعمومی تجاری، مالی، فنی، قراردادها، گزارش فروش و سایر اطلاعات محرمانه طرف دیگر را جز برای اجرای قرارداد یا به حکم قانون افشا نکند." - }, - { - "type": "paragraph", - "text": "این تعهد پس از پایان قرارداد نیز باقی خواهد ماند." - } - ] - }, - { - "number": 25, - "heading": "ماده ۲۵ ـ پذیرش و اعتبار الکترونیکی", - "blocks": [ - { - "type": "clause", - "text": "۲۵ـ۱. این قرارداد به صورت الکترونیکی منعقد می‌شود." - }, - { - "type": "clause", - "text": "۲۵ـ۲. برگزارکننده با انتخاب گزینه «مفاد قرارداد را مطالعه کردم و می‌پذیرم» و تکمیل فرایند تأیید هویت، رضایت صریح خود را به انعقاد قرارداد اعلام می‌کند." - }, - { - "type": "clause", - "text": "۲۵ـ۳. قبیله می‌تواند برای اثبات پذیرش قرارداد، اطلاعاتی از قبیل شناسه حساب، زمان پذیرش، نسخه قرارداد، شماره تلفن تأییدشده، سابقه ارسال و تأیید کد یک‌بارمصرف، نشانی اینترنتی اتصال و سایر سوابق فنی مرتبط را نگهداری کند." - }, - { - "type": "clause", - "text": "۲۵ـ۴. نسخه قابل مطالعه قرارداد باید قبل از پذیرش در اختیار برگزارکننده قرار گیرد و امکان دسترسی بعدی به نسخه پذیرفته‌شده برای وی فراهم باشد." - }, - { - "type": "clause", - "text": "۲۵ـ۵. فعالیت‌هایی که پس از ورود معتبر به حساب برگزارکننده انجام می‌شود تا زمانی که برگزارکننده وقوع دسترسی غیرمجاز را اعلام نکرده باشد، به حساب همان برگزارکننده منظور می‌شود؛ این امر مانع بررسی ادعای مستند دسترسی غیرمجاز نخواهد بود." - } - ] - }, - { - "number": 26, - "heading": "ماده ۲۶ ـ تغییر قرارداد", - "blocks": [ - { - "type": "clause", - "text": "۲۶ـ۱. قبیله می‌تواند به علت تغییر قانون، تغییر خدمات یا ضرورت عملیاتی، مفاد قرارداد را اصلاح کند." - }, - { - "type": "clause", - "text": "۲۶ـ۲. تغییرات اساسی باید پیش از لازم‌الاجرا شدن از طریق پنل، پیامک، نشانی الکترونیکی یا سایر روش‌های ثبت‌شده به برگزارکننده اطلاع داده شود." - }, - { - "type": "clause", - "text": "۲۶ـ۳. تغییر شرایط مالی یا تعهدات اساسی مربوط به رویدادی که قبلاً بلیط آن فروخته شده است، جز در صورت الزام قانونی یا پذیرش برگزارکننده، نسبت به گذشته اعمال نمی‌شود." - }, - { - "type": "clause", - "text": "۲۶ـ۴. ادامه استفاده از خدمات پس از تاریخ اجرای نسخه جدید، در مواردی که قانون اجازه دهد، به منزله پذیرش نسخه جدید خواهد بود؛ در مواردی که رضایت صریح لازم باشد، قبیله مجدداً پذیرش الکترونیکی دریافت خواهد کرد." - } - ] - }, - { - "number": 27, - "heading": "ماده ۲۷ ـ ابلاغ و ارتباطات", - "blocks": [ - { - "type": "paragraph", - "text": "نشانی، شماره تلفن و سایر راه‌های ارتباطی ثبت‌شده در حساب برگزارکننده، راه ارتباط رسمی طرفین محسوب می‌شود." - }, - { - "type": "paragraph", - "text": "برگزارکننده موظف است تغییر اطلاعات تماس یا نماینده خود را فوراً ثبت کند." - }, - { - "type": "paragraph", - "text": "پیام‌ها و ابلاغ‌های ارسال‌شده از طریق پنل، پیامک یا سایر روش‌های ثبت‌شده در صورتی که امکان اثبات ارسال یا دریافت آنها وجود داشته باشد، در روابط قراردادی طرفین قابل استناد خواهد بود." - } - ] - }, - { - "number": 28, - "heading": "ماده ۲۸ ـ حل اختلاف و قانون حاکم", - "blocks": [ - { - "type": "clause", - "text": "۲۸ـ۱. این قرارداد تابع قوانین جمهوری اسلامی ایران است." - }, - { - "type": "clause", - "text": "۲۸ـ۲. طرفین تلاش خواهند کرد اختلاف را ابتدا از طریق مذاکره و تبادل مستندات حل کنند." - }, - { - "type": "clause", - "text": "۲۸ـ۳. اگر ظرف ده روز کاری از اعلام کتبی اختلاف توافق حاصل نشود، هر یک از طرفین می‌تواند به مرجع صالح قانونی مراجعه کند." - }, - { - "type": "clause", - "text": "۲۸ـ۴. در حدودی که قواعد آمره صلاحیت اجازه دهد، دعاوی قراردادی میان قبیله و برگزارکننده در مراجع صالح محل اقامت قانونی قبیله قابل طرح خواهد بود." - }, - { - "type": "clause", - "text": "۲۸ـ۵. این ماده نافی صلاحیت مراجع قانونی در مواردی که قانون صلاحیت خاص یا انحصاری تعیین کرده است نیست." - } - ] - }, - { - "number": 29, - "heading": "ماده ۲۹ ـ سایر شرایط", - "blocks": [ - { - "type": "clause", - "text": "۲۹ـ1. اگر بخشی از قرارداد به موجب حکم قطعی یا قانون غیرقابل اجرا شناخته شود، سایر مفاد تا حد امکان معتبر باقی خواهد ماند." - }, - { - "type": "clause", - "text": "۲۹ـ2. برگزارکننده بدون موافقت قبیله حق انتقال قرارداد یا حساب برگزارکنندگی خود به شخص ثالث را ندارد." - }, - { - "type": "clause", - "text": "۲۹ـ3. شرایط مالی هر رویداد، اطلاعات تأییدشده در پنل، سیاست‌های لازم‌الاجرای اعلام‌شده توسط قبیله و شرایط اختصاصی رویداد در حدودی که مغایر قوانین آمره نباشند، جزء این قرارداد محسوب می‌شوند." - } - ] - }, - { - "number": 30, - "heading": "ماده ۳۰ ـ اقرار نهایی برگزارکننده", - "blocks": [ - { - "type": "paragraph", - "text": "برگزارکننده با پذیرش این قرارداد اقرار می‌کند که:" - }, - { - "type": "numbered_item", - "text": "۱. مفاد قرارداد را پیش از پذیرش مطالعه کرده است؛" - }, - { - "type": "numbered_item", - "text": "۲. اطلاعات هویتی، بانکی و مالیاتی ارائه‌شده صحیح است؛" - }, - { - "type": "numbered_item", - "text": "۳. در صورت فعالیت به نمایندگی از شخص حقوقی، اختیار لازم را دارد؛" - }, - { - "type": "numbered_item", - "text": "۴. قبیله برگزارکننده یا ارائه‌دهنده اصلی رویداد نیست؛" - }, - { - "type": "numbered_item", - "text": "۵. مسئولیت اجرای رویداد، اخذ مجوزها، صحت اطلاعات و حقوق شرکت‌کنندگان مرتبط با اصل رویداد بر عهده برگزارکننده است؛" - }, - { - "type": "numbered_item", - "text": "۶. قبیله مجاز است وجوه بلیط را به حساب برگزارکننده وصول و پس از کسر حق‌العمل و کسورات مجاز تسویه کند؛" - }, - { - "type": "numbered_item", - "text": "۷. در صورت لغو، استرداد یا ایجاد بدهی، قبیله حق کسر یا تهاتر مبالغ مربوط از وجوه برگزارکننده را دارد؛" - }, - { - "type": "numbered_item", - "text": "۸. سوابق پذیرش الکترونیکی قرارداد در حدود قوانین قابل استناد است." - } - ] - } - ] -} diff --git a/content/publicPages.json b/content/publicPages.json deleted file mode 100644 index 2e833e3..0000000 --- a/content/publicPages.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "contact": { - "title": "کنارتان هستیم", - "eyebrow": "تماس با ما", - "description": "اگر درباره رزرو، میزبانی یا حساب خود سؤال دارید، پیام بگذارید. برای پیگیری سریع‌تر، شماره موبایل حساب و جزئیات مرتبط را بنویسید.", - "metaTitle": "تماس با پشتیبانی", - "metaDescription": "برای پرسش درباره رزرو، میزبانی یا حساب کاربری با پشتیبانی قبیله تماس بگیرید. پیام بگذارید تا پیگیری شود.", - "jsonLdName": "تماس با قبیله", - "phone": { - "label": "شماره تماس و واتساپ", - "display": "۰۹۱۵ ۳۶۴ ۱۱۹۶" - }, - "address": { - "label": "نشانی", - "display": "مشهد، کوثر ۲۸، پلاک ۱۲۲، شرکت رهام", - "locality": "مشهد", - "street": "کوثر ۲۸، پلاک ۱۲۲، شرکت رهام" - }, - "hours": { - "label": "ساعات پاسخ‌گویی", - "display": "شنبه تا چهارشنبه، ساعت ۹ تا ۱۷" - }, - "faq": { - "label": "پیش از ارسال", - "linkLabel": "پاسخ سؤال‌های پرتکرار را ببینید ←", - "href": "/faq" - }, - "form": { - "nameLabel": "نام و نام خانوادگی", - "mobileLabel": "شماره تماس", - "subjectLabel": "موضوع", - "messageLabel": "پیام شما", - "submitLabel": "ارسال پیام", - "successMessage": "پیام شما ثبت شد. به‌زودی پاسخ می‌دهیم." - } - } -} diff --git a/content/publicPages.ts b/content/publicPages.ts deleted file mode 100644 index 112316d..0000000 --- a/content/publicPages.ts +++ /dev/null @@ -1,24 +0,0 @@ -import publicPagesData from '@/content/publicPages.json' - -export interface PublicContactPage { - title: string - eyebrow: string - description: string - metaTitle: string - metaDescription: string - jsonLdName: string - phone: { label: string; display: string } - address: { label: string; display: string; locality: string; street: string } - hours: { label: string; display: string } - faq: { label: string; linkLabel: string; href: string } - form: { - nameLabel: string - mobileLabel: string - subjectLabel: string - messageLabel: string - submitLabel: string - successMessage: string - } -} - -export const contactPage: PublicContactPage = publicPagesData.contact diff --git a/docs/consumer-caching.md b/docs/consumer-caching.md deleted file mode 100644 index e8c7f73..0000000 --- a/docs/consumer-caching.md +++ /dev/null @@ -1,184 +0,0 @@ -# Consumer data caching (TanStack Query) - -The consumer app (`app/(consumer)/**`, guest and host pages alike) fetches -and caches server data through [TanStack Query](https://tanstack.com/query) -v5, not ad-hoc `useState` + `useEffect`. This replaced a per-page pattern of -manual loading/error state and full-list reloads after every mutation. Read -this before adding a new fetch, list, or mutation under `(consumer)`. - -**Full-stack caching (guest ISR vs logged-in dynamic, Next Data Cache, Redis):** -see [`caching-strategy.md`](./caching-strategy.md) — read that first when -changing SSR, `revalidate`, or public discovery endpoints. - -## Core pieces - -- **`lib/queryClient.ts`** — `makeQueryClient()` builds the `QueryClient` - with the project defaults (`staleTime: 30s`, `gcTime: 5min`, - `refetchOnWindowFocus/Reconnect: true`, `retry: 1`). `getQueryClient()` is - the browser-side singleton accessor: on the server it always returns a - fresh client (no cross-request state leak); in the browser it memoizes one - instance in a module-level variable so client-side navigations share a - single cache instead of rebuilding it per page. -- **`app/providers.tsx`** — wraps the app in `QueryClientProvider`, seeded - from `getQueryClient()` via `useState(() => getQueryClient())` (so the - provider itself doesn't recreate the client on re-render). -- **`queries/consumerKeys.ts`** — the single query-key factory. Every query - key used by consumer code should come from here, not be hand-written - inline — it's what makes cache patches and invalidations from one file - reliably reach queries defined in another. Read the file's own comment - about `following()` being a _prefix_ of `followingCount()`/`isFollowing()` - before adding a new key in that shape; pass `{ exact: true }` when you mean - only the list itself. -- **`queries/unwrapService.ts`** — every service function in this codebase - returns a `ServiceResult` (`{ ok, data }` or `{ ok: false, error }`), - but TanStack Query wants a `queryFn` that either resolves or throws. - `unwrapService(await SOME_SERVICE_CALL(...))` bridges the two: pass - `{ errorMode: 'parent' }` to the service call so its own error handling - doesn't swallow the failure before `unwrapService` gets to throw it. - -## Query/mutation hook layer (`queries/consumer/*.ts`) - -One file per data domain (`useWalletQuery.ts`, `useBookingQueries.ts`, -`useFollowingQueries.ts`, …), each exporting plain hooks — no classes, no -shared base hook. Conventions to follow: - -- Accept an `enabled = true` parameter on read hooks so callers can gate a - query behind auth state or a tab being active, without duplicating the - query definition. -- Call `unwrapService(await SOME_SERVICE(..., { errorMode: 'parent' }))` - inside `queryFn` for anything that returns a `ServiceResult`; pass through - `signal` when the underlying service call accepts one, so navigating away - cancels the in-flight request. -- Mutations live next to the queries they affect (e.g. - `useCancelBookingMutation.ts`, `useHostedEventMutations.ts`), not inside - page components. A mutation's `onSuccess` should patch the cache directly - with `queryClient.setQueryData(key, updater)` for the specific - list/record it changed, rather than a blanket - `invalidateQueries` + refetch — this keeps the UI from flashing a full - reload and preserves scroll position. Only fall back to - `invalidateQueries` for data the mutation doesn't have the fresh shape of - in hand (e.g. cancelling a booking invalidates `wallet()` because the - refund amount isn't known client-side). -- Because mutation hooks patch the shared cache directly, list-item - components (`BookingListItem`, `HostedEventListItem`, …) don't need - `onChanged`/`onDeleted` callback props threaded up to the page — each row - calls its own mutation hook and the shared list query updates itself. - -## Paginated lists: always `useInfiniteQuery`, never manual page-accumulation - -Every paginated list (`following`/`followers`, discovery events, chat -messages) uses `useInfiniteQuery`, even the ones with an unusual pagination -shape (chat — see below). **Do not** build pagination by hand on top of a -plain `useQuery` (fetch page 1 in `queryFn`, then `setQueryData` to prepend -further pages on "load more"). That shape is a live bug: `useQuery`'s -`queryFn` has no idea a second page was ever merged in, so any background -refetch — window refocus, reconnect, or just the query going stale — calls -`queryFn` again, which re-fetches _only_ page 1 and silently overwrites the -whole accumulated list. `useInfiniteQuery` doesn't have this problem: a -refetch replays every page currently in `data.pages`, each with the exact -`pageParam` it was originally fetched with, so accumulated history survives -a background refresh. - -**Chat messages** (`features/chat/useChatThread.ts`) are the one -non-obvious case: the API always returns the _newest_ window first with a -stable `before` cursor for going further back, so page 0 (fetched with -`pageParam: undefined`) is the newest page, and pages fetched via -`fetchNextPage()` afterwards are progressively _older_. Rendering therefore -reverses the `pages` array before flattening (`[...pages].reverse()`, each -page's own items already oldest→newest) to get a chronological thread. New -messages — from `SEND_MESSAGE` or the chat socket — get appended into -`pages[0]` (the newest page), never onto a flat array, via a shared -`appendMessageToCache` helper. - -`fetchNextPage()` **swallows errors by default** — the underlying promise -is `.catch(noop)`'d unless you pass `{ throwOnError: true }`. Every -"load more" handler in this codebase that wraps `fetchNextPage()` in a -try/catch (`ConsumerHomeDiscovery.tsx`, `useChatThread.ts`) passes -`throwOnError: true`, otherwise the catch block is dead code and load-more -failures fail silently. `following`/`followers` don't need this because -they don't wrap the call in a try/catch — they read the error reactively -off `query.error`/`query.isError` instead, which TanStack still sets -correctly even when the promise itself is swallowed. - -## Seeding the cache from SSR data - -`ConsumerHomeDiscovery.tsx` receives server-rendered `initialCategories` / -`initialCities` / `initialHomeFeed` as props (from `app/(consumer)/page.tsx`, -ISR `revalidate = 60` plus `next: { revalidate }` on `lib/seo/serverApi.ts` -fetches — see [`caching-strategy.md`](./caching-strategy.md) §4). Those props -seed the query cache's `initialData` / `placeholderData` -instead of the client re-fetching on mount — but only for the -_default/no-filter_ selection, since that's the only case the SSR fetch -matches. Changing a filter creates a new query key with no `initialData` -and fetches normally. If you add a new SSR-seeded query, gate its -`initialData` the same way (`isDefaultSelection && ...`) or you'll seed -stale data for a filter combination the server never actually fetched. - -## Bottom-nav tab keep-alive (UI layer) - -TanStack Query caches **data** across tab switches; this layer caches **mounted page -trees** for the four bottom-nav roots (`/`, `/my-events`, `/chats`, `/profile`). - -- **`components/consumer/ConsumerBottomNavKeepAlive.tsx`** — lazy-mounts each tab once, - keeps inactive panels in the DOM (`hidden` + `inert`), and gives each tab its own - `overflow-y-auto` scroller (`data-consumer-tab-panel="{tab}"`). Each panel also - provides `ConsumerTabActivationContext`: visible panels report active; hidden - panels report inactive; pages outside this keep-alive shell default to active. -- **`lib/consumerTabKeepAlive.ts`** — tracks which tabs have been mounted so - `app/(consumer)/loading.tsx` can skip the route skeleton on revisit. -- **`app/(consumer)/layout.tsx`** — tab roots render inside the keep-alive shell; nested - routes (e.g. `/profile/wallet`, `/category/...`) render in a stack layer above the - hidden panels. Event soft-nav (`/e/...`) still uses the existing overlay pin logic. -- **Scroll** — per-tab scroll lives on each panel scroller (`lib/consumerTabScroll.ts`); - pull-to-refresh on home reads the active panel via `ConsumerTabPanelScrollContext`. - -Keep-alive preserves component state; it must not keep expensive background -work active. Root-tab queries combine their existing eligibility condition with -`useIsConsumerTabActive()`. The Chats root also pauses its page-level socket -subscription, and Home disables pull-to-refresh listeners while hidden. The -consumer-shell socket transport and global unread badge remain active by design. -When a tab becomes visible again, its cached UI is immediate and stale enabled -queries revalidate normally. - -Nested discovery URLs under Home (`/category/...`, `/city/...`) are **not** tab roots: -they use the stack layer while the home panel stays mounted underneath. - -## Session data: `AuthContext.user` vs. the `me()` query cache - -`AuthContext.user` (bootstrapped synchronously from `localStorage`, no -network call) and `consumerKeys.me()` (the live `useMeQuery()` cache, -`services/users.ts`'s `GET_ME`/`PATCH_ME`) are two copies of overlapping -profile fields (name, avatar, bio, city, default address). Don't -hand-sync them from a mutation's `onSuccess` — `AuthContext.tsx` subscribes -to the query cache once, at the provider level, and mirrors `me()` into -`user` automatically whenever that cache changes (`setQueryData`, -invalidate-then-refetch, background refetch — anything). A mutation only -needs to call `queryClient.setQueryData(consumerKeys.me(), result.data)` (or -`invalidateQueries`); the profile fields on `useAuth().user` update on -their own. This also means `logout()` clearing the whole cache -(`getQueryClient().clear()`) can't leave a previous user's profile fields -sitting in `AuthContext.user` after the redirect. - -## Testing - -Any component or hook under test that calls `useQuery`/`useMutation`/ -`useQueryClient` needs a `QueryClientProvider` in the test tree — there is -no ambient one. The convention across this codebase's tests -(`BookingActions.test.tsx`, `OrganizerFollowButton.test.tsx`, -`useProfileAccount.test.ts`, …) is a small local helper: - -```ts -const renderWithQuery = (ui: React.ReactElement) => { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }) - - return render({ui}) -} -``` - -(`renderHook` needs the same client, passed as its `wrapper` option, when -testing a hook directly instead of a component.) Turn `retry` off in the -test client — otherwise a deliberately-failing mock service call retries -before the query settles into its error state, and `waitFor` assertions on -error UI become flaky/slow. diff --git a/docs/consumer-texts.md b/docs/consumer-texts.md deleted file mode 100644 index b4c99b8..0000000 --- a/docs/consumer-texts.md +++ /dev/null @@ -1,71 +0,0 @@ -# Consumer texts catalog - -Single-locale (Persian) string catalog for **non-admin** UI. Not multi-language i18n. - -## Location - -```text -frontend/texts/ - index.ts # export const texts = { … }; export { format } - format.ts # {name} placeholder helper - common.ts - errors.ts - auth.ts - discovery.ts - bookings.ts - chats.ts - events.ts - profile.ts - wallet.ts - identity.ts - support.ts - notifications.ts - reviews.ts - publicSite.ts - blog.ts - seo.ts - status.ts - validation.ts -``` - -Import: - -```ts -import { texts, format } from '@/texts' -``` - -## Usage - -```ts -texts.common.retry -texts.bookings.cancelConfirm -format(texts.events.shareLinkCopied, { channel: 'تلگرام' }) -``` - -- Prefer **dot access** (`texts.domain.key`) so Go to Definition jumps to the string. -- Keys follow **product domain**, not route/page names. -- Shared actions/labels → `texts.common`. -- Product API codes / HTTP fallbacks → `texts.errors` (consumed by `services/apiErrorLocalization.ts`). -- Status chip labels → `texts.status` (via `types/status.ts`). -- Zod messages for consumer forms → `texts.validation.*`. - -## Scope - -| In | Out | -| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| `(consumer)`, `(public)`, auth, event create/manage/detail for hosts/guests, SEO landings, blog | `app/(dashboard)/**` admin panel (unless already shared) | -| Shared consumer chrome (nav, toasts, upload, feedback) | Second-locale / `next-intl` | - -## Conventions - -1. New consumer UI string → add to the right domain file, then reference `texts.*`. -2. Do not hardcode Persian literals in non-admin components (tests may still assert the Persian text). -3. Brand display spelling is always **قبیله**. -4. Avoid duplicate keys with the same Persian value; reuse `common` / existing keys when possible. -5. Interpolation uses `{key}` placeholders and `format()`. - -## Related - -- Cursor rule: `.cursor/rules/consumer-texts.mdc` -- Brand spelling: `.cursor/rules/persian-brand-name.mdc` -- Consumer UI (modals + mobile): [`consumer-ui-guidelines.md`](./consumer-ui-guidelines.md) · rules `consumer-modals`, `consumer-mobile-first` diff --git a/docs/consumer-ui-guidelines.md b/docs/consumer-ui-guidelines.md deleted file mode 100644 index a5be6ef..0000000 --- a/docs/consumer-ui-guidelines.md +++ /dev/null @@ -1,161 +0,0 @@ -# Consumer UI guidelines - -Hard constraints for the end-user app under `frontend/app/(consumer)/` and -shared consumer surfaces (`components/consumer/*`, PWA/auth overlays used -there, event/review/booking flows). - -Agents must follow the always-on Cursor rules -`consumer-modals`, `consumer-input`, `consumer-button`, and `consumer-mobile-first`. This doc is the longer -companion. - -## 1. Overlays: ConsumerModal only - -Any modal / dialog in the consumer app must use -`components/consumer/ConsumerModal`. - -```tsx -// ❌ BAD — admin/shared Modal in consumer UI -import Modal from '@/components/modals/Modal' - -// ✅ GOOD -import ConsumerModal from '@/components/consumer/ConsumerModal' -``` - -Do not use `components/modals/Modal`, HeroUI `Drawer`, or ad-hoc fixed -banners for consumer dialogs. Admin stays on `components/modals/Modal` -(see `admin-ui-guidelines.md`). - -`ConsumerModal` is always a **bottom sheet** (phone and desktop). There is no -centered / top / auto placement. - -## 3. Form fields: ConsumerInput only - -Any text field, select, textarea, OTP, radio, checkbox, switch, or number -input in the consumer app must use `components/consumer/ConsumerInput`. - -```tsx -// ❌ BAD — admin/shared Input in consumer UI -import Input from '@/components/formElements/Input' - -// ✅ GOOD -import ConsumerInput from '@/components/consumer/ConsumerInput' -``` - -Do not use `components/formElements/Input` in consumer surfaces. - -**Props:** use `size` (`sm` | `md` | `lg`), `radius` -(`full` | `control`, 10px), and `tone` (`muted` | `bordered`). `muted` applies -`#E2E2E280` fill; `bordered` is white surface + border. **Select is always -`muted`.** Do not pass HeroUI -`size`, `radius`, or `variant`. - -**Standalone mode:** when the parent is not wrapped in `react-hook-form`, pass -`value` and `onValueChange` — `ConsumerInput` creates an internal form bridge. - -**Custom wrappers:** pass `inputWrapper` to merge classes (auth full-radius -fields, host contact section). - -**OTP styling:** pass `otpClassNames={{ segment, segmentWrapper }}` when the -default consumer OTP look is not enough (e.g. auth gate). - -**Temporary exception:** `birthDatePicker`, `combobox`, and other admin-only -field kinds not yet in ConsumerInput v1 may keep `formElements/Input` for that -single field until support is added. - -Implementation lives under `components/consumer/input/` (`ConsumerFieldShell`, -`consumerInputStyles.ts`, per-kind controls). Admin panel and event-create wizard -stay on `formElements/Input`. - -## 4. Actions: ConsumerButton only - -Any button, submit control, or link-styled action in the consumer app must use -`components/consumer/ConsumerButton`. - -```tsx -// ❌ BAD — admin/shared Button in consumer UI -import Button from '@/components/formElements/Button' - -// ✅ GOOD -import ConsumerButton from '@/components/consumer/ConsumerButton' -``` - -Do not use `components/formElements/Button` in consumer surfaces. - -**Props:** use `fill` (`orange` | `navy` | `gray` | `none`), `size` -(`xs` | `sm` | `md` | `lg` | `xl`), `radius` (`full` | `control`), `textColor`, -`fontSize`, `align`, and icon props (`iconStart` / `iconEnd` / `iconOnly`). Do not -pass HeroUI `size`, `radius`, `variant`, or `color`. - -**Size tokens (button heights):** - -| `size` | height | -| ------ | ----------------------: | -| `xs` | 40px | -| `sm` | 44px | -| `md` | 48px | -| `lg` | 52px (default text CTA) | -| `xl` | 60px | - -Default: `lg` for text buttons, `sm` for `iconOnly`. - -**هم‌نام بودن ≠ هم‌اندازه بودن:** `ConsumerButton` `size` and `ConsumerInput` -`size` reuse labels (`sm`, `md`, `lg`) but map to **different pixel -heights**. Do not assume a field and a button with the same token name share -one height. - -| token | `ConsumerButton` `size` | `ConsumerInput` `size` | -| ----- | ----------------------: | ---------------------: | -| `xs` | 40px | — | -| `sm` | 44px | 32px | -| `md` | 48px | 48px | -| `lg` | 52px | 52px | -| `xl` | 60px | — | - -**Four button roles (+ icon chrome):** - -| Role | API | Typical use | -| ------------------- | ----------------------------------------- | --------------------------------- | -| Conversion CTA | `fill="orange"` (default) | login, book, continue | -| Dark primary | `fill="navy"` | modal confirm, host CTAs | -| Inline / card pill | `fill="gray"` | cancel booking, nav chips, follow | -| Text / ghost action | `fill="none"` + `textColor` | change mobile, resend code, links | -| Destructive | `fill="none"` + `textColor="text-fourth-900"` | delete, cancel reservation | -| Icon-only | `iconOnly` + `size="sm"` | bookmark, share, edit on cards | - -**Dual cancel/primary rows:** `ConsumerActionButtons` (modal footers, -`ConsumerFormActionBar`). Do not duplicate that layout. - -**No ad-hoc visual `className`:** if Figma needs a look this component cannot -express via `fill`, `size`, `radius`, `textColor`, `fontSize`, or `align`, stop -and ask the product owner — do not patch with `bg-*`, `text-*`, `rounded-*`, etc. -Extend `ConsumerButton` only after explicit approval. Layout-only classes -(`flex-1`, `mt-5`, `shrink-0`, …) are fine. - -Admin panel stays on `formElements/Button`. - -## 5. Mobile excellence; desktop does not matter - -The consumer app is a **mobile product**. - -- Target phone widths (~360–430px) first and last. -- No horizontal scroll, clipped content, overlapping BottomNav, or ignored - safe-area insets. -- Touch targets ≥ 44px; primary CTAs reachable with one thumb when practical. -- Fix mobile layout bugs before any other visual work. - -**Non-goal:** desktop / wide-viewport polish. Do not invest in multi-column -desktop layouts or large-screen aesthetics unless explicitly requested. A -correct phone layout always wins over a polished desktop layout. - -Design tokens and patterns: [`docs/frontend/consumer-design-system.md`](../../docs/frontend/consumer-design-system.md). -Copy catalog: [`consumer-texts.md`](./consumer-texts.md). - -## 6. Event cards - -Use the single medium card `components/consumer/EventCard` everywhere. - -- Fixed top: poster + date / time / category / location + title. -- Variable bottom: pass page-specific UI via `children` (inline `div`s). -- Optional helper: `EventCapacityBadge` for remaining-seat chips in discovery/host footers. -- Loading state: `EventCardSkeleton` / `EventCardSkeletonList`. -- Dense lists: `CompactEventCard` or `ConsumerEventThumbRow` when the medium card is too tall. diff --git a/docs/deploy.md b/docs/deploy.md index b79dcc6..f155305 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -5,6 +5,9 @@ 3. Configure the reverse proxy to forward `backoffice.ghabilee.ir` to `127.0.0.1:3009` and enable TLS. 4. Push to `main` (or run the deploy workflow manually) after the environment is ready. +Deploy Telegram alerts use admin-specific copy in `scripts/notify-deploy.sh` +(success → backoffice URL; distinct from the consumer frontend message). + ## GitHub secrets checkpoint Deployment requires `VPS_HOST`, `VPS_USER`, and `VPS_SSH_KEY`. The repository owner will add these GitHub Actions secrets later, before the first deployment. diff --git a/docs/documents/about.html b/docs/documents/about.html deleted file mode 100644 index 11fced4..0000000 --- a/docs/documents/about.html +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - درباره قبیله | جایی برای تجربه‌های حضوری و جمع‌های تازه - - - - -
    -
    - ققبیله - -
    - -
    -
    درباره قبیله
    -

    ما هنوز هم دور چیزهایی که دوست داریم جمع می‌شیم.

    -

    فقط شکلش عوض شده.

    یه روز دور آتیش، امروز دور یه میز، یه بازی، یه کارگاه، یه اجرا یا هر چیزی که ارزش بیرون اومدن و با هم تجربه کردن داشته باشه. -
    -قبیله برای همین جمع ها ساخته شده

    - -
    - -
    -
    غریزه قدیمی، زندگی امروز
    -

    هزاران ساله
    همین کارو می‌کنیم.

    -

    آدم‌ها همیشه دنبال آدم‌های خودشون بودن؛ آدم‌هایی که یه چیز مشترک دارن. یه علاقه، یه کنجکاوی، یه مهارت، یه سلیقه یا حتی فقط حال‌وهوای یه شب.

    -
    -

    دور آتیش

    یه نقطه برای جمع شدن، حرف زدن، تجربه کردن و ساختن چیزی مشترک.

    -

    دور یه میز، یه بازی، یه اجرا

    بهونه‌ها عوض شدن، ولی نیاز به جمع هنوز همونه.

    -

    پیدا کردن این جمع‌ها همیشه راحت نیست

    خیلی از تجربه‌ها بین چند صفحه، پیام، لینک پرداخت و گروه پراکنده می‌شن. خیلی‌ها هم اصلاً نمی‌فهمن دوروبرشون چه خبره.

    -
    -
    قبیله می‌خواد این فاصله رو کمتر کنه.
    -
    - -
    -
    قبیله دقیقاً چیه؟
    -

    جایی برای پیدا کردن،
    رفتن و جمع شدن.

    -

    قبیله جاییه برای پیدا کردن و تجربه کردن رویدادهای حضوری. می‌تونی ببینی این دور و برا چه خبره، یه تجربه تازه پیدا کنی، جات رو توی یه جمع نگه داری و اگه چیزی برای ساختن داری، خودت میزبان بشی.

    -
    کشف کنببین این دور و برا چه خبره.
    جاتو نگه داررزرو و پرداخت یک‌جا.
    آدم‌ها رو پیدا کنمیزبان‌ها، نظرها و تجربه‌های مشترک.
    میزبان شویه تجربه بساز و جمعش رو راه بنداز.
    -
    - -
    -
    بهونه‌های خوب
    -

    هر جمعی
    یه بهونه می‌خواد.

    - -
    -

    یه کارگاه

    یه چیزی یاد بگیری و آدم‌هایی رو ببینی که همون کنجکاوی رو دارن.

    -

    یه بازی

    یه شب معمولی رو به یه خاطره مشترک تبدیل کنی.

    -

    یه اجرا

    چیزی رو ببینی که شاید توی مسیر معمول روزمره بهش نمی‌رسیدی.

    -

    یه تجربه تازه

    چیزی که هنوز امتحانش نکردی و شاید بخوای دوباره برگردی سراغش.

    -
    -
    برای ما خود «رویداد» آخر داستان نیست؛ فقط بهونه‌ایه که چند نفر برای چند ساعت از مسیرهای جدا بیان و یه تجربه رو با هم زندگی کنن.
    -
    بعضی از این جمع ها همون شب تموم میشن. بعضی ها تبدیل به یه خاطره میشن و بعضی ها باعث میشن آدم هایی رو پیدا کنی که دلت بخواد دوباره ببینیشون.
    - -
    - -
    -
    دو مسیر، یک جمع
    -

    یه وقت می‌خوای بری.
    یه وقت می‌خوای بسازی.

    -
    -
    برای کسی که می‌خواد بره

    همه تجربه‌های خوب از مدت‌ها قبل برنامه‌ریزی نمی‌شن.

    گاهی چهارشنبه‌ست و فقط می‌خوای ببینی آخر هفته چه خبره. گاهی مدت‌هاست دلت می‌خواد چیزی رو امتحان کنی ولی کسی رو پیدا نکردی که باهات بیاد.

    گاهی فقط یه رویداد می‌بینی و با خودت می‌گی: «این بار برم.»
    -
    برای کسی که می‌خواد یه جمع بسازه

    پشت هر تجربه خوب، یه نفر تصمیم گرفته چیزی رو راه بندازه.

    قبیله کمک می‌کنه ثبت‌نام‌ها بین پیام‌ها گم نشن، پرداخت‌ها یک‌جا بمونن و ظرفیت، مهمان‌ها و فهرست انتظار قابل مدیریت باشن.

    ما به این آدم می‌گیم: میزبان.
    -
    -
    - -
    -
    چیزی که می‌خوایم بسازیم
    - -

    چیزی که کم داریم، جاهایی برای پیدا کردن آدم‌ها و تجربه‌هاییه که شاید توی مسیر معمول زندگی بهشون نمی‌رسیدیم.

    یه تصمیم ساده تبدیل به یه شب متفاوت بشه.
    یه علاقه شخصی تبدیل به یه تجربه مشترک بشه.
    یه میزبان آدم‌های درست رو دور چیزی که ساخته جمع کنه.
    یه تجربه خوب، ردپایی برای جمع بعدی بذاره.
    -
    - -
    -
    چیزهایی که برای ما مهمه
    -

    چند اصل ساده
    برای یه جمع بهتر.

    -
    -
    ۴

    تجربه در دنیای واقعی

    قبیله روی صفحه شروع می‌شه، اما چیزی که برای ما مهمه بیرون از صفحه اتفاق می‌افته.

    ۱

    آدم‌ها قبل از عددها

    رشد وقتی ارزش داره که تجربه آدم‌هایی که وارد این جمع می‌شن خراب نشه.

    -
    ۵

    جمع، نه فقط برنامه

    گاهی چیزی که از یه رویداد با خودت می‌بری فقط چیزی نیست که یاد گرفتی یا دیدی؛ آدم‌هاییه که اونجا پیدا کردی.

    ۲

    شفاف بودن قبل از خرید

    باید بدونی کجا می‌ری، برای چی می‌ری، چه چیزی منتظرته و چقدر پرداخت می‌کنی.

    -
    ۳

    اعتماد دوطرفه

    هم مهمان باید بدونه پشت یه رویداد چه کسیه، هم میزبان باید بدونه چه کسانی ثبت‌نام کردن.

    - - -
    -
    - -
    - -

    شاید جمع بعدی
    همین دور و برا باشه.

    - - -
    - -
    قبیله · روایت مدرن غریزه قدیمی دور هم جمع شدن.
    -
    - - - - - \ No newline at end of file diff --git a/docs/documents/become-a-host.html b/docs/documents/become-a-host.html deleted file mode 100644 index 6100d68..0000000 --- a/docs/documents/become-a-host.html +++ /dev/null @@ -1,1021 +0,0 @@ - - - - - - - برگزارکننده شو | قبیله - - - - - -
    - -
    - - ق - قبیله - - -
    - -
    -
    برگزارکننده شو
    -

    یه رویداد می‌خوای بسازی؟

    -

    - یه کارگاه، یه بازی، یه اجرا، یه سفر، یه دورهمی یا هر چیزی که فکر می‌کنی بهتره با چند نفر اتفاق بیفته. -

    - تو تجربه رو بساز. قبیله ثبت‌نام، پرداخت و مهمان‌ها رو جمع‌وجور می‌کنه. -

    - - اولین رویدادم رو می‌سازم -
    احراز هویت کن، رویدادت رو بساز و وقتی آماده شد منتشرش کن.
    - -
    -
    ۲۴ثبت‌نام قطعی
    -
    8نفر در انتظار
    -
    24نفر حاضر
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    - -
    - ۷٪ کارمزداز فروش موفق -
    -
    -
    - -
    - رزرو و پرداختیک‌جا -
    -
    -
    - -
    - ظرفیت و مهمان‌هامرتب و قابل پیگیری -
    -
    -
    - -
    - یادآوری مداومقبل از شروع -
    -
    -
    - -
    -
    -
    قبل از شروع
    -

    یه رویداد خوب،
    قبل از شروعش کلی کار داره

    - - -
    -
    هنوز جا دارید؟
    -
    پول رو کجا واریز کنم؟
    -
    ثبت‌نامم قطعی شده؟
    -
    آدرس دقیق کجاست؟
    -
    من پول دادم، اسمم هست؟
    -
    اگه یکی نیاد چی؟
    -
    - -
    قرار نیست انرژیت صرف سوالهای تکراری بشه
    -
    -
    - -
    -
    حضور مهمان‌ها
    -

    ثبت‌نام کرده.
    ولی روز برنامه واقعاً میاد؟

    -

    - پر شدن ظرفیت همیشه یعنی پر شدن صندلی‌ها نیست. گاهی مهمان چند روز قبل ثبت‌نام کرده و روز برنامه یادش می‌ره که جایی منتظرشه. -

    -

    قبیله قبل از رویداد یادش می‌اندازه.

    - -
    -
    -
    ق
    -
    - فردا می‌بینیمت -

    رویداد «شب بازی» فردا ساعت ۱۹ شروع می‌شه. زمان و جزئیات رویدادت رو یه بار دیگه ببین.

    - قبیله · همین حالا -
    -
    -
    - -
    -
    کمتر «یادم رفت»
    -
    کمتر جای خالی
    -
    -
    - -
    -
    مسیر یک رویداد
    -

    از اولین ثبت‌نام
    تا آخرین مهمان

    -

    قبیله بخش‌های تکراری مسیر رو مرتب نگه می‌داره تا تو روی خود تجربه تمرکز کنی.

    - -
    -
    -
    قبل از فروش
    -

    رویدادت یه جای مشخص داره

    -

    زمان، مکان، ظرفیت، قیمت و همه چیزهایی که مهمان باید بدونه یک‌جاست.

    -
    -
    صفحه رویدادمنتشرشده
    -
    -
    -
    - -
    -
    وقتی ثبت‌نام‌ها شروع می‌شن
    -

    رزرو و پرداخت کنار همن

    -

    می‌دونی کی قطعی شده، کی هنوز پرداخت نکرده و وضعیت هر رزرو چیه.

    -
    -
    رزروها۲۴ نفر
    -
    -
    -
    - -
    -
    وقتی ظرفیت پر می‌شه
    -

    اگه جا باز شد، هدر نمی‌ره

    -

    وقتی ظرفیت دوباره آزاد بشه، نفر بعدی می‌تونه فرصت ثبت‌نام پیدا کنه.

    -
    -
    فهرست انتظار۳ نفر
    -
    -
    -
    - -
    -
    روز رویداد
    -

    ببین کی واقعاً اومده

    -

    فهرست مهمان‌ها جلوی چشمته و حضور مهمان‌های قطعی رو ثبت می‌کنی.

    -
    -
    حضور۱۸ از ۲۴
    -
    -
    -
    -
    -
    - -
    -
    جزئیات مهم
    -

    چیزهایی که وسط کار
    خیلی به درد می‌خورن

    - -
    -
    -

    آدرس برای همه نیست

    -

    می‌تونی آدرس دقیق رو فقط به کسایی نشون بدی که رزرو قطعی دارن.

    -
    -
    قبل از رزروسجاد، مشهد
    -
    بعد از رزرو
    -
    -
    - -
    -

    یه جمع خاص؟ یه کد مخصوص

    -

    برای رویدادهای پولی کد تخفیف درصدی یا مبلغ ثابت بساز و محدودیتش رو خودت تعیین کن.

    -
    -
    TRIBE20۲۰٪ تخفیف
    -
    تعداد استفاده۱۲ / ۳۰
    -
    -
    - -
    -

    میتونم رویدادم رو تغییر بدم؟

    -

    تنظیمات غیر حساس رو میتونی تغییر بدی، تا حد امکان موقع ثبت و تایید نهایی دقت کن.

    -
    -
    ظرفیت جدید5 + 30
    -
    اعلان مهمان‌هافعال
    -
    -
    -
    -
    - -
    -
    میز کار برگزارکننده
    -

    یه رویداد.
    یه میز کار.

    -

    برای فهمیدن اینکه چه خبره لازم نیست پنج جا رو باز کنی.

    - -
    -
    -
    شب بازی · خلاصهمنتشرشده
    -
    -
    ۲۴ثبت‌نام قطعی
    -
    ۱۸حاضر
    -
    ۳در انتظار
    -
    ۹.۳مبعد از کارمزد
    -
    -
    -
    مهمان‌ها
    انتظار
    مالی
    -
    -
    -
    - -

    ثبت‌نام، حضور، فهرست انتظار، تخفیف، نظرها و وضعیت مالی، همه کنار همن.

    - رویدادم رو می‌سازم -
    - -
    -
    هزینه قبیله
    -

    عددش از اول
    روشنه.

    - -
    -
    ٪۷
    -

    از فروش موفق رویداد

    - -
    -
    ساخت رویدادرایگان
    -
    فروش موفق۷٪ کارمزد
    -
    فروش نداشتیچیزی کم نمی‌شه
    -
    -
    - - -
    - -
    -
    شروع کار
    -

    چهار قدم
    تا اولین رویداد

    - -
    -
    -
    ۱
    -

    خودت رو معرفی کن

    احراز هویتت رو کامل کن تا امکان میزبانی فعال بشه.

    -
    -
    -
    ۲
    -

    رویدادت رو بساز

    موضوع، زمان، مکان، قیمت و ظرفیت رو مشخص کن.

    -
    -
    -
    ۳
    -

    منتشرش کن

    صفحه رویدادت آماده می‌شه و می‌تونی لینکش رو هرجا جمعت هست بفرستی.

    -
    -
    -
    ۴
    -

    میزبان باش

    قبیله ثبت‌نام‌ها رو مرتب نگه می‌داره؛ تو تجربه رو بساز.

    -
    -
    -
    - -
    -
    راهنمای کوتاه
    -

    اولین باره
    رویداد می‌سازی؟

    -

    قبل از انتشار، این چندتا چیز رو روشن کن.

    - -
    -
    - آدم‌ها برای چی میان؟ -
    تو یه جمله بتونی جواب بدی. آخر این برنامه قراره چی تجربه کنن، چی یاد بگیرن، چی ببینن یا چه حسی با خودشون ببرن؟ لازم نیست عجیب بنویسی؛ فقط واضح باش.
    -
    -
    - رویدادت برای چه کسیه؟ -
    اگه سطح خاص، محدودیت سنی یا وسیله لازم داره قبل از خرید بگو. هر چیزی که ممکنه مهمان بعداً بگه «کاش قبلش می‌دونستم»، باید قبلش گفته بشه.
    -
    -
    - ظرفیت واقعی چقدره؟ -
    ظرفیت فقط تعداد صندلی‌ها نیست. یه اتاق ممکنه ۳۰ نفر جا داشته باشه، اما شاید تجربه تو با ۱۲ نفر بهتر اتفاق بیفته.
    -
    -
    - قیمت رو چطور انتخاب کنم؟ -
    هزینه مکان، مواد، تجهیزات، زمان و ارزشی که تجربه برای مهمان داره رو کنار هم ببین. خیلی ارزان بودن همیشه به معنی راحت‌تر فروختن نیست.
    -
    -
    - روز رویداد چی رو چک کنم؟ -
    کمی زودتر برس، فضا و تجهیزات رو چک کن، فهرست مهمان‌ها رو آماده داشته باش و برای چیزهایی که ممکنه خراب بشن یه راه دوم داشته باش.
    -
    -
    -
    - -
    -
    سؤال‌های قبل از شروع
    -

    چیزی مونده
    که باید بدونی؟

    - -
    -
    - کارمزد قبیله چقدره؟ -
    ۷٪ از فروش موفق رویداد.
    -
    -
    - ساخت رویداد هزینه داره؟ -
    برای ساخت رویداد هزینه‌ای ازت گرفته نمی‌شه. کارمزد قبیله از فروش موفق محاسبه می‌شه.
    -
    -
    - برای میزبانی باید احراز هویت کنم؟ -
    بله. قبل از فعال‌شدن امکان ساخت رویداد، احراز هویت برگزارکننده لازمه.
    -
    -
    - اگه مهمان روز برنامه یادش بره چی؟ -
    قبیله قبل از رویداد برای مهمان یادآوری می‌فرسته. این کار حضور رو تضمین نمی‌کنه، اما کمک می‌کنه «یادم رفت» کمتر اتفاق بیفته.
    -
    -
    - اگه ظرفیت پر بشه چی؟ -
    رزرو مستقیم متوقف می‌شه و مهمان‌های بعدی می‌تونن وارد فهرست انتظار بشن. اگر جایی آزاد بشه، نفر بعد فرصت ثبت‌نام پیدا می‌کنه.
    -
    -
    - اگه رویداد رو لغو کنم چی؟ -
    رزروهای رویداد لغو می‌شن و فرایند اطلاع‌رسانی و بازپرداخت طبق قوانین رویداد و قبیله انجام می‌شه.
    -
    -
    - می‌تونم به مهمان‌ها تخفیف بدم؟ -
    برای رویداد پولی می‌تونی کد تخفیف درصدی یا مبلغ ثابت بسازی و محدودیت استفاده براش تعیین کنی.
    -
    -
    - بعد از رویداد چه اتفاقی می‌افته؟ -
    مهمان‌های واجد شرایط می‌تونن نظر بدن، تو می‌تونی پاسخ بدی و سابقه رویدادها روی پروفایلت می‌مونه.
    -
    -
    -
    - -
    -
    از یه جا باید شروع بشه
    -

    یه جمع، قبل از اینکه جمع بشه،
    فقط یه ایده‌ست.

    -

    - شاید الان فقط یه موضوع توی ذهنت داری؛ یه بازی، یه چیزی که بلدی یا یه تجربه که فکر می‌کنی چند نفر دیگه هم باید امتحانش کنن. -

    - اولین رویدادم رو می‌سازم -
    ساخت رویداد رایگانه · کارمزد فقط از فروش موفق
    -
    - -
    قبیله · آدم‌ها دور چیزهایی که دوست دارن جمع می‌شن.
    -
    - - - - - - - \ No newline at end of file diff --git a/docs/documents/cancellationandFund.docx b/docs/documents/cancellationandFund.docx deleted file mode 100644 index dde4896..0000000 Binary files a/docs/documents/cancellationandFund.docx and /dev/null differ diff --git a/docs/documents/categoriesText.docx b/docs/documents/categoriesText.docx deleted file mode 100644 index 332dd62..0000000 Binary files a/docs/documents/categoriesText.docx and /dev/null differ diff --git a/docs/documents/category-sample.html b/docs/documents/category-sample.html deleted file mode 100644 index 627d441..0000000 --- a/docs/documents/category-sample.html +++ /dev/null @@ -1,469 +0,0 @@ - - - - - - هنر و فرهنگ | قبیله - - - -
    - -
    - -
    - - -
    -
    -
    رویدادهای هنر و فرهنگ
    -

    هنر و فرهنگ

    -

    بعضی جمع‌ها دور یک داستان شروع می‌شن.

    -

    گاهی یک نمایشگاه، یک اجرای زنده یا یک گفت‌وگوی ساده بهانه‌ای می‌شود برای بیرون آمدن از روزمرگی، دیدن چیزی تازه و پیدا کردن آدم‌هایی که همان چیزهایی را دوست دارند که تو دوست داری.

    - -
    -
    -
    هنر فقط چیزی نیست که می‌بینی؛ بخشی از ماجرا آدم‌هایین که کنارت تجربه‌ش می‌کنن.
    -
    -
    - -
    -
    -
    -

    این روزها کجا می‌شه رفت؟

    -

    رویدادهای هنر و فرهنگ که الان یا به‌زودی برگزار می‌شوند.

    -
    -
    ۱۵ رویداد
    -
    - -
    - - -
    - -
    - - - - - - -
    - -
    - ۱۵ نتیجه پیدا شد - -
    - -
    -
    پنجشنبه
    نمایشگاه «شهر بعد از تاریکی»
    نمایشگاه عکاسی شهری
    ۱۸ شهریور · ۱۸:۰۰خانه هنرمندان
    از ۲۵۰ هزار توماناستودیو قاب
    -
    جمعه
    شب شعر؛ چند دقیقه بعد از نیمه‌شب
    شعر، موسیقی و یک جمع کوچک
    ۱۹ شهریور · ۲۰:۰۰کافه روشن
    ۱۸۰ هزار تومانجمع واژه‌ها
    -
    شنبه
    اجرای زنده؛ صداهای یک اتاق
    اجرای مستقل موسیقی
    ۲۰ شهریور · ۱۹:۳۰استودیو شماره ۴
    از ۳۲۰ هزار تومانخانه صدا
    -
    یکشنبه
    یک عصر با سینمای عباس کیارستمی
    تماشا و گفت‌وگو درباره سینما
    ۲۱ شهریور · ۱۷:۰۰خانه فیلم
    رایگانجمع سینما
    -
    دوشنبه
    نقاشی بدون بلد بودن
    برای کسانی که فقط می‌خواهند شروع کنند
    ۲۲ شهریور · ۱۸:۳۰کارگاه هفت
    ۲۹۰ هزار تومانسارا نیک‌پی
    -
    سه‌شنبه
    گالری‌گردی در مرکز شهر
    سه گالری، یک مسیر و یک عصر متفاوت
    ۲۳ شهریور · ۱۶:۰۰مرکز شهر
    ۲۲۰ هزار تومانپیاده‌گرد
    -
    چهارشنبه
    پرفورمنس «بدن و شهر»
    یک تجربه نمایشی نزدیک و بی‌واسطه
    ۲۴ شهریور · ۲۰:۳۰پلتفرم ۲۸
    ۳۶۰ هزار تومانگروه کژ
    -
    پنجشنبه
    داستان‌خوانی؛ روایت‌های ناتمام
    داستان کوتاه، چای و گفت‌وگو
    ۲۵ شهریور · ۱۹:۰۰خانه ادبیات
    ۱۵۰ هزار تومانروایت نو
    -
    جمعه
    تهران از پشت پنجره‌های قدیمی
    گردش فرهنگی در چند خانه تاریخی
    ۲۶ شهریور · ۱۰:۰۰عودلاجان
    ۳۱۰ هزار تومانکوچه‌گرد
    -
    شنبه
    شنیدن یک آلبوم از اول تا آخر
    شنیدن جمعی و گفت‌وگوی بعد از موسیقی
    ۲۷ شهریور · ۲۰:۰۰اتاق صدا
    ۲۰۰ هزار تومانموج
    -
    یکشنبه
    معماری و حافظه شهر
    یک نشست برای دیدن شهر از زاویه‌ای دیگر
    ۲۸ شهریور · ۱۸:۰۰خانه گفتگو
    رایگانخط شهر
    -
    دوشنبه
    سفال برای یک عصر
    ساختن با دست، بدون تجربه قبلی
    ۲۹ شهریور · ۱۷:۳۰کارگاه خاک
    ۴۲۰ هزار توماناستودیو خاک
    -
    سه‌شنبه
    نمایش فیلم کوتاه؛ پنج نگاه
    پنج فیلم و گفت‌وگو با سازندگان
    ۳۰ شهریور · ۱۹:۳۰سالن کوچک
    ۲۶۰ هزار تومانقاب کوتاه
    -
    چهارشنبه
    خط و کاغذ؛ تجربه خوشنویسی آزاد
    یک جلسه آرام برای دست و ذهن
    ۳۱ شهریور · ۱۸:۰۰خانه کاغذ
    ۳۴۰ هزار تومانالهام راد
    -
    پنجشنبه
    کتاب‌هایی که ما را عوض کردند
    گفت‌وگو درباره کتاب‌هایی که ماندند
    ۱ مهر · ۱۹:۰۰کافه کتاب
    رایگانجمع خواندن
    -
    - - -
    - -
    -
    -
    بیشتر درباره هنر و فرهنگ
    -

    یک بهانه قدیمی برای دور هم جمع شدن

    -

    خیلی قبل‌تر از گالری، سالن اجرا، سینما و کافه هم آدم‌ها دور داستان، موسیقی، تصویر و روایت جمع می‌شدند. شکل این جمع‌ها عوض شده، اما میل ما به تجربه کردن هنر کنار دیگران هنوز همان است.

    -

    امروز ممکن است این جمع دور یک نمایشگاه عکاسی شکل بگیرد، فردا در یک سالن کوچک تئاتر و هفته بعد در یک کافه برای شنیدن شعر، دیدن فیلم یا حرف زدن درباره یک کتاب.

    -

    گاهی برای دیدن یک نمایشگاه می‌روی و با هنرمندی آشنا می‌شوی که تا دیروز اسمش را هم نشنیده بودی. گاهی برای یک اجرای زنده بلیط می‌گیری و چند ساعت کنار آدم‌هایی قرار می‌گیری که سلیقه‌ای نزدیک به تو دارند. بعضی وقت‌ها هم فقط از سر کنجکاوی وارد یک جمع می‌شوی و با چیزی روبه‌رو می‌شوی که تا قبل از آن نمی‌شناختی.

    - - - -

    چرا در یک رویداد هنری یا فرهنگی شرکت کنیم؟

    -

    قرار نیست همیشه دلیل بزرگی برای بیرون رفتن داشته باشیم. گاهی فقط می‌خواهی شب شبیه شب‌های قبل نباشد؛ چیزی ببینی که قبلاً ندیده‌ای، موسیقی متفاوتی بشنوی، درباره موضوعی حرف بزنی که در زندگی روزمره کمتر فرصت حرف زدن درباره‌اش پیش می‌آید یا با آدم‌هایی روبه‌رو شوی که همان چیزها برایشان جالب است.

    -

    رویدادهای هنری و فرهنگی می‌توانند فرصتی باشند برای کشف تجربه‌های تازه، آشنا شدن با آثار و آدم‌های جدید، فاصله گرفتن از برنامه‌های تکراری و پیدا کردن علاقه‌هایی که شاید تا امروز فرصتی برای امتحان کردنشان نداشته‌ای.

    - -
    بعضی وقت‌ها یک شب خوب فقط با این جمله شروع می‌شه: «بریم ببینیم چه خبره.»
    - -

    آیا برای شرکت در رویدادهای هنری باید چیزی از هنر بدانم؟

    -

    نه. برای رفتن به یک نمایشگاه لازم نیست نقاش باشی، برای شرکت در شب شعر لازم نیست شاعر باشی و برای یک نشست سینمایی هم لازم نیست تاریخ سینما را از بر باشی.

    -

    بخش زیادی از رویدادهای هنر و فرهنگ برای آدم‌هایی ساخته می‌شوند که فقط علاقه‌مند یا کنجکاوند. اگر یک رویداد نیاز به دانش، مهارت یا پیش‌نیاز خاصی داشته باشد، برگزارکننده آن را در توضیحات رویداد مشخص می‌کند.

    - -

    اگر تنها باشم، می‌توانم در یک رویداد شرکت کنم؟

    -

    حتماً لازم نیست برای هر تجربه‌ای از قبل همراه داشته باشی. خیلی از رویدادها محیط طبیعی‌تری برای آشنا شدن با آدم‌های تازه هستند، چون کسانی که آنجا حضور دارند از قبل یک نقطه مشترک دارند: همه برای دیدن، شنیدن یا تجربه کردن یک موضوع مشخص آمده‌اند.

    -

    لازم نیست حتماً با کسی دوست شوی یا وارد گفت‌وگوی طولانی شوی. حتی حضور در یک جمع تازه، بدون هیچ انتظار دیگری، می‌تواند تجربه متفاوتی باشد.

    - -

    چطور یک رویداد هنری مناسب خودم پیدا کنم؟

    -

    اول از خودت بپرس این بار دنبال چه حال‌وهوایی هستی. می‌خواهی چیزی ببینی یا چیزی بسازی؟ دنبال یک اجرای زنده‌ای یا یک جمع کوچک برای گفت‌وگو؟ دوست داری چند ساعت آرام در یک نمایشگاه بگردی یا در تجربه‌ای مشارکتی حضور داشته باشی؟

    -

    موضوع رویداد، تصاویر، توضیحات برگزارکننده، زمان، محل برگزاری، قیمت و نوع برنامه می‌توانند به انتخاب کمک کنند. اما بعضی وقت‌ها هم لازم نیست انتخاب را خیلی پیچیده کنی.

    - -

    رویدادهای کوچک هم ارزش رفتن دارند؟

    -

    بزرگ بودن یک رویداد الزاماً به معنی بهتر بودن آن نیست. گاهی بهترین تجربه‌ها در یک جمع ده یا بیست نفره اتفاق می‌افتند؛ جایی که می‌توانی راحت‌تر با برگزارکننده یا آدم‌های دیگر ارتباط بگیری و حس کنی واقعاً بخشی از آن جمع هستی.

    - -

    چرا شناخت برگزارکننده مهم است؟

    -

    برگزارکننده یا همان میزبان، بخش مهمی از تجربه هر رویداد است. او تصمیم گرفته چه آدم‌هایی را دور چه موضوعی جمع کند، برنامه چطور پیش برود و شرکت‌کنندگان قرار است چه چیزی را تجربه کنند.

    -

    پیش از انتخاب یک رویداد می‌توانی اطلاعات برگزارکننده، توضیحات برنامه و رویدادهایی را که قبلاً برگزار کرده بررسی کنی تا تصویر روشن‌تری از تجربه پیش رو داشته باشی.

    -
    -
    - -
    -
    -
    برای میزبان‌ها
    -

    این طرف ماجرا تو میزبان باش.

    -

    یک نمایشگاه کوچک، اجرای مستقل، نشست پانزده‌نفره یا یک تجربه تازه؛ برای شروع لازم نیست جمع بزرگی داشته باشی. تو چیزی می‌سازی که ارزش دور هم جمع شدن دارد. قبیله کمک می‌کند آدم‌ها پیدایش کنند.

    -
    -
    رویدادت رو معرفی کن
    -
    ثبت‌نام‌ها رو یک‌جا داشته باش
    -
    بلیط بفروش
    -
    با شرکت‌کننده‌ها در ارتباط باش
    -
    - رویدادت رو بساز -
    -
    -
    - -
    -

    پرسش‌های رایج

    -
    - چه برنامه‌هایی در دسته هنر و فرهنگ قرار می‌گیرند؟ -

    نمایشگاه، گالری‌گردی، تئاتر، موسیقی و اجرای زنده، شب شعر، رونمایی کتاب، نشست فرهنگی، کارگاه هنری، موزه‌گردی و تجربه‌های مستقل و ترکیبی.

    -
    -
    - برای شرکت در رویداد هنری به تجربه قبلی نیاز دارم؟ -

    معمولاً نه. اگر یک رویداد پیش‌نیاز خاصی داشته باشد، برگزارکننده آن را در توضیحات مشخص می‌کند.

    -
    -
    - همه رویدادها بلیط دارند؟ -

    نه. بعضی رایگان‌اند و بعضی با تهیه بلیط برگزار می‌شوند. شرایط هر رویداد در صفحه خودش مشخص است.

    -
    -
    - می‌توانم تنها در یک رویداد شرکت کنم؟ -

    بله. خیلی از آدم‌ها به تنهایی وارد یک رویداد می‌شوند و یک علاقه مشترک می‌تواند شروع طبیعی‌تری برای ارتباط باشد.

    -
    -
    - رویداد کوچک هم می‌توانم ثبت کنم؟ -

    بله. یک نشست کوچک، اجرای مستقل یا دورهمی محدود هم می‌تواند یک رویداد باشد.

    -
    -
    -
    - - - - \ No newline at end of file diff --git a/docs/documents/privacy.docx b/docs/documents/privacy.docx deleted file mode 100644 index 87fd39f..0000000 Binary files a/docs/documents/privacy.docx and /dev/null differ diff --git a/docs/documents/termsofUse.docx b/docs/documents/termsofUse.docx deleted file mode 100644 index 7933b16..0000000 Binary files a/docs/documents/termsofUse.docx and /dev/null differ diff --git a/docs/event cart/discovry/Medium Style Cart.png b/docs/event cart/discovry/Medium Style Cart.png deleted file mode 100644 index e48949e..0000000 Binary files a/docs/event cart/discovry/Medium Style Cart.png and /dev/null differ diff --git a/docs/event cart/host/Medium Style Cart (1).png b/docs/event cart/host/Medium Style Cart (1).png deleted file mode 100644 index e9de866..0000000 Binary files a/docs/event cart/host/Medium Style Cart (1).png and /dev/null differ diff --git a/docs/event cart/host/Medium Style Cart (2).png b/docs/event cart/host/Medium Style Cart (2).png deleted file mode 100644 index 57406ad..0000000 Binary files a/docs/event cart/host/Medium Style Cart (2).png and /dev/null differ diff --git a/docs/event cart/host/Medium Style Cart Placed.png b/docs/event cart/host/Medium Style Cart Placed.png deleted file mode 100644 index 11d6433..0000000 Binary files a/docs/event cart/host/Medium Style Cart Placed.png and /dev/null differ diff --git a/docs/event cart/host/Medium Style Cart-editable.png b/docs/event cart/host/Medium Style Cart-editable.png deleted file mode 100644 index b7b3785..0000000 Binary files a/docs/event cart/host/Medium Style Cart-editable.png and /dev/null differ diff --git a/docs/event cart/host/Medium Style Cart.png b/docs/event cart/host/Medium Style Cart.png deleted file mode 100644 index 3978d7e..0000000 Binary files a/docs/event cart/host/Medium Style Cart.png and /dev/null differ diff --git a/docs/event cart/my-event as a guest/Medium Style Cart status Done.png b/docs/event cart/my-event as a guest/Medium Style Cart status Done.png deleted file mode 100644 index 4532497..0000000 Binary files a/docs/event cart/my-event as a guest/Medium Style Cart status Done.png and /dev/null differ diff --git a/docs/event cart/my-event as a guest/Medium Style Cart status Postponed.png b/docs/event cart/my-event as a guest/Medium Style Cart status Postponed.png deleted file mode 100644 index ed2c941..0000000 Binary files a/docs/event cart/my-event as a guest/Medium Style Cart status Postponed.png and /dev/null differ diff --git a/docs/event cart/my-event as a guest/Medium Style Cart status- waiting to buy.png b/docs/event cart/my-event as a guest/Medium Style Cart status- waiting to buy.png deleted file mode 100644 index 5f34985..0000000 Binary files a/docs/event cart/my-event as a guest/Medium Style Cart status- waiting to buy.png and /dev/null differ diff --git a/docs/event cart/my-event as a guest/Medium Style Cart status.png b/docs/event cart/my-event as a guest/Medium Style Cart status.png deleted file mode 100644 index 9662559..0000000 Binary files a/docs/event cart/my-event as a guest/Medium Style Cart status.png and /dev/null differ diff --git a/docs/login and signup/Age selection modal.png b/docs/login and signup/Age selection modal.png deleted file mode 100644 index d6fae27..0000000 Binary files a/docs/login and signup/Age selection modal.png and /dev/null differ diff --git a/docs/login and signup/City selection modal.png b/docs/login and signup/City selection modal.png deleted file mode 100644 index e6f8408..0000000 Binary files a/docs/login and signup/City selection modal.png and /dev/null differ diff --git a/docs/login and signup/Frame 6257 (1).png b/docs/login and signup/Frame 6257 (1).png deleted file mode 100644 index 6a408ef..0000000 Binary files a/docs/login and signup/Frame 6257 (1).png and /dev/null differ diff --git a/docs/login and signup/Frame 6257 (2).png b/docs/login and signup/Frame 6257 (2).png deleted file mode 100644 index 4da259c..0000000 Binary files a/docs/login and signup/Frame 6257 (2).png and /dev/null differ diff --git a/docs/login and signup/Frame 6257 (3).png b/docs/login and signup/Frame 6257 (3).png deleted file mode 100644 index 87bae23..0000000 Binary files a/docs/login and signup/Frame 6257 (3).png and /dev/null differ diff --git a/docs/login and signup/Frame 6257.png b/docs/login and signup/Frame 6257.png deleted file mode 100644 index 1bb4063..0000000 Binary files a/docs/login and signup/Frame 6257.png and /dev/null differ diff --git a/docs/myProfile/Ghabilee Web app-profile (1).png b/docs/myProfile/Ghabilee Web app-profile (1).png deleted file mode 100644 index 857effa..0000000 Binary files a/docs/myProfile/Ghabilee Web app-profile (1).png and /dev/null differ diff --git a/docs/myProfile/Ghabilee Web app-profile.png b/docs/myProfile/Ghabilee Web app-profile.png deleted file mode 100644 index eaeaa4a..0000000 Binary files a/docs/myProfile/Ghabilee Web app-profile.png and /dev/null differ diff --git a/docs/myProfile/حالت خالی ایونت.png b/docs/myProfile/حالت خالی ایونت.png deleted file mode 100644 index 0e29660..0000000 Binary files a/docs/myProfile/حالت خالی ایونت.png and /dev/null differ diff --git a/docs/myProfile/حالت خالی تجربه.png b/docs/myProfile/حالت خالی تجربه.png deleted file mode 100644 index 3b7b865..0000000 Binary files a/docs/myProfile/حالت خالی تجربه.png and /dev/null differ diff --git a/docs/seo-gsc-indexing-urls.md b/docs/seo-gsc-indexing-urls.md deleted file mode 100644 index 5f36ac8..0000000 --- a/docs/seo-gsc-indexing-urls.md +++ /dev/null @@ -1,175 +0,0 @@ -# Google Search Console — URLهای ایندکس - -بعد از deploy یا تغییر مهم SEO، در [Search Console → URL Inspection](https://search.google.com/search-console) هر URL را Paste کن. - -- اگر **URL is on Google** بود و محتوا عوض شده → در صورت نیاز **Request indexing** -- اگر **URL is not on Google** بود → **Request indexing** -- Sitemap سابمیت‌شده: `https://ghabilee.ir/sitemap.xml` (در Sitemaps فقط `sitemap.xml` هم کافی است) - -محدودیت روزانه Request indexing گوگل را رعایت کن؛ اول بخش «ثابت و محصول» را بزن. - -اسلاگ شهرها انگلیسی رایج است (migration بک‌اند `81_readable_city_slugs`). اسلاگ‌های قدیمی transliterate با ۳۰۸ به همین‌ها می‌روند: - -| شهر | اسلاگ canonical | اسلاگ قدیمی (ریدایرکت) | -| ------ | --------------- | ---------------------- | -| تهران | `tehran` | `thran` | -| مشهد | `mashhad` | `mshd` | -| اصفهان | `isfahan` | `asfhan` | -| شیراز | `shiraz` | `syraz` | -| تبریز | `tabriz` | `tbryz` | -| اهواز | `ahvaz` | — | - -اگر شهر جدیدی در ادمین اضافه شد، الگوی URL همان است: `/city/{slug}` و `/category/{categorySlug}/city/{slug}`. فهرست زنده شهرها: https://ghabilee.ir/city - ---- - -## ۱) صفحات ثابت و محصول - -``` -https://ghabilee.ir/ -https://ghabilee.ir/category -https://ghabilee.ir/city -https://ghabilee.ir/blog -https://ghabilee.ir/about -https://ghabilee.ir/faq -https://ghabilee.ir/become-a-host -https://ghabilee.ir/host-guide -https://ghabilee.ir/contact -https://ghabilee.ir/terms -https://ghabilee.ir/privacy -https://ghabilee.ir/refund-policy -``` - ---- - -## ۲) صفحهٔ هر دسته‌بندی (۶) - -``` -https://ghabilee.ir/category/arts-culture -https://ghabilee.ir/category/learning-experience -https://ghabilee.ir/category/games-entertainment -https://ghabilee.ir/category/sports-adventure -https://ghabilee.ir/category/food-gatherings -https://ghabilee.ir/category/conversation-connection -``` - ---- - -## ۳) صفحهٔ هر شهر (۶ شهر فعال محصول) - -``` -https://ghabilee.ir/city/tehran -https://ghabilee.ir/city/mashhad -https://ghabilee.ir/city/isfahan -https://ghabilee.ir/city/shiraz -https://ghabilee.ir/city/tabriz -https://ghabilee.ir/city/ahvaz -``` - ---- - -## ۴) ترکیب هر دسته با هر شهر (۶ × ۶ = ۳۶) - -بعد از تغییر کد، این صفحات **indexable** هستند حتی اگر رویداد فعالی نداشته باشند. - -### تهران (`tehran`) - -``` -https://ghabilee.ir/category/arts-culture/city/tehran -https://ghabilee.ir/category/learning-experience/city/tehran -https://ghabilee.ir/category/games-entertainment/city/tehran -https://ghabilee.ir/category/sports-adventure/city/tehran -https://ghabilee.ir/category/food-gatherings/city/tehran -https://ghabilee.ir/category/conversation-connection/city/tehran -``` - -### مشهد (`mashhad`) - -``` -https://ghabilee.ir/category/arts-culture/city/mashhad -https://ghabilee.ir/category/learning-experience/city/mashhad -https://ghabilee.ir/category/games-entertainment/city/mashhad -https://ghabilee.ir/category/sports-adventure/city/mashhad -https://ghabilee.ir/category/food-gatherings/city/mashhad -https://ghabilee.ir/category/conversation-connection/city/mashhad -``` - -### اصفهان (`isfahan`) - -``` -https://ghabilee.ir/category/arts-culture/city/isfahan -https://ghabilee.ir/category/learning-experience/city/isfahan -https://ghabilee.ir/category/games-entertainment/city/isfahan -https://ghabilee.ir/category/sports-adventure/city/isfahan -https://ghabilee.ir/category/food-gatherings/city/isfahan -https://ghabilee.ir/category/conversation-connection/city/isfahan -``` - -### شیراز (`shiraz`) - -``` -https://ghabilee.ir/category/arts-culture/city/shiraz -https://ghabilee.ir/category/learning-experience/city/shiraz -https://ghabilee.ir/category/games-entertainment/city/shiraz -https://ghabilee.ir/category/sports-adventure/city/shiraz -https://ghabilee.ir/category/food-gatherings/city/shiraz -https://ghabilee.ir/category/conversation-connection/city/shiraz -``` - -### تبریز (`tabriz`) - -``` -https://ghabilee.ir/category/arts-culture/city/tabriz -https://ghabilee.ir/category/learning-experience/city/tabriz -https://ghabilee.ir/category/games-entertainment/city/tabriz -https://ghabilee.ir/category/sports-adventure/city/tabriz -https://ghabilee.ir/category/food-gatherings/city/tabriz -https://ghabilee.ir/category/conversation-connection/city/tabriz -``` - -### اهواز (`ahvaz`) - -``` -https://ghabilee.ir/category/arts-culture/city/ahvaz -https://ghabilee.ir/category/learning-experience/city/ahvaz -https://ghabilee.ir/category/games-entertainment/city/ahvaz -https://ghabilee.ir/category/sports-adventure/city/ahvaz -https://ghabilee.ir/category/food-gatherings/city/ahvaz -https://ghabilee.ir/category/conversation-connection/city/ahvaz -``` - ---- - -## ۵) کشف برای مدل‌های زبانی - -گوگل ممکن است `.txt` را مثل صفحهٔ HTML در SERP نشان ندهد؛ برای کشف crawler / AI همچنان Request indexing مفید است. - -``` -https://ghabilee.ir/llms.txt -https://ghabilee.ir/llms-full.txt -https://ghabilee.ir/llms-qa.txt -https://ghabilee.ir/robots.txt -https://ghabilee.ir/sitemap.xml -``` - ---- - -## جمع صفحات این لیست - -- ثابت / محصول: ۱۲ -- دسته: ۶ -- شهر: ۶ -- combo: ۳۶ -- LLM / کشف: ۵ - -جمع برای Request indexing دستی: **۶۵** (به‌علاوه رویدادها و مقالات که از sitemap می‌آیند و معمولاً لازم نیست یکی‌یکی بزنی). - ---- - -## چک بعد از چند روز - -1. Sitemaps → Status = Success و Discovered pages > 0 -2. Page indexing → خطاهای جدید را مرور کن -3. چند URL بخش ۱ و ۲ را دوباره Inspect کن - -آخرین به‌روزرسانی لیست: ۱۴۰۵/۰۶/۱۲ (سپتامبر ۲۰۲۶) diff --git a/e2e/fixtures/blogApiMock.ts b/e2e/fixtures/blogApiMock.ts deleted file mode 100644 index 3410205..0000000 --- a/e2e/fixtures/blogApiMock.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** Minimal published article payloads for SSR blog e2e (see e2e/global-setup.ts). */ - -export const E2E_MOCK_API_HOST = '127.0.0.1' -export const E2E_MOCK_API_PORT = 3000 - -export const e2eBlogArticleSlug = 'e2e-sample-article' - -export const e2eBlogArticleSummary = { - id: 9001, - slug: e2eBlogArticleSlug, - title: 'مقاله نمونه برای تست انتها به انتها', - excerpt: 'خلاصه کوتاه برای کارت مقاله در لیست وبلاگ.', - categorySlug: 'city-guides', - categoryName: 'راهنمای شهرها', - city: { id: 1, slug: 'tehran', name: 'تهران' }, - eventCategory: null, - featuredImageUrl: null, - readingMinutes: 5, - isFeatured: false, - publishedAt: '2030-01-15T10:00:00.000Z', - createdAt: '2030-01-10T10:00:00.000Z', - updatedAt: '2030-01-15T10:00:00.000Z', -} - -export const e2eBlogArticleDetail = { - ...e2eBlogArticleSummary, - metaTitle: null, - metaDescription: null, - bodyHtml: '

    متن نمونه مقاله برای تست Playwright.

    ', -} diff --git a/e2e/fixtures/consumerAuth.ts b/e2e/fixtures/consumerAuth.ts deleted file mode 100644 index e027b42..0000000 --- a/e2e/fixtures/consumerAuth.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { expect, type Page, type Route } from '@playwright/test' - -export const CONSUMER_MOBILE = '09123456789' -export const CONSUMER_MOBILE_PERSIAN = '۰۹۱۲۳۴۵۶۷۸۹' -export const CONSUMER_OTP = '1234' -export const DEFAULT_BIRTH_DATE_ISO = '1991-03-21' - -export const jsonOk = (data: unknown) => ({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data }), -}) - -export const installApiCatchAll = async (page: Page) => { - await page.route('**/api/v1/**', (route) => - route.fulfill( - jsonOk({ - items: [], - meta: {}, - response: { page: 1, pageSize: 20, totalItemsCount: 0, totalPages: 1 }, - }) - ) - ) - // Guest home client-refetches these; catch-all object shapes crash render (.map/.filter). - await page.route('**/api/v1/discovery/cities**', (route) => - route.fulfill(jsonOk([{ id: 1, provinceId: 1, name: 'تهران', slug: 'tehran' }])) - ) - await page.route('**/api/v1/discovery/categories**', (route) => route.fulfill(jsonOk([]))) - await page.route('**/api/v1/discovery/home-feed**', (route) => - route.fulfill(jsonOk({ popular: [], categoryPreviews: [], cityPreviews: [] })) - ) -} - -export const createConsumerAccessToken = (status: 'active' | 'pending' = 'active') => { - const payload = Buffer.from( - JSON.stringify({ - sub: 'user-id', - role: 'user', - sessionId: 'session-id', - status, - exp: Math.floor(Date.now() / 1000) + 3600, - }) - ).toString('base64url') - - return `header.${payload}.signature` -} - -export const mockRequestOtp = async (page: Page, purpose: 'login' | 'register', onRequest?: (body: { mobile?: string }) => void) => { - await page.route('**/api/v1/auth/request-otp', async (route) => { - const body = (route.request().postDataJSON() ?? {}) as { mobile?: string } - - onRequest?.(body) - await route.fulfill(jsonOk({ purpose, expiresIn: 120, alreadySent: false })) - }) -} - -export const mockVerifyOtp = async ( - page: Page, - options: { - status: 'active' | 'pending' - onRequest?: (body: Record) => void - accessToken?: string - } -) => { - const accessToken = options.accessToken ?? createConsumerAccessToken(options.status) - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() - - await page.route('**/api/v1/auth/verify-otp', async (route) => { - const body = (route.request().postDataJSON() ?? {}) as Record - - options.onRequest?.(body) - await route.fulfill( - jsonOk({ - accessToken, - sessionId: 'session-id', - expiresAt, - status: options.status, - }) - ) - }) - - return accessToken -} - -export const mockCities = async (page: Page) => { - await page.route('**/api/v1/geography/cities', (route) => - route.fulfill(jsonOk([{ id: 1, provinceId: 1, name: 'تهران', slug: 'tehran' }])) - ) -} - -export const mockCompleteProfile = async (page: Page, onRequest?: (body: Record) => void) => { - await page.route('**/api/v1/users/me/complete-profile', async (route) => { - const body = (route.request().postDataJSON() ?? {}) as Record - - onRequest?.(body) - await route.fulfill( - jsonOk({ - id: 'user-id', - firstName: 'علی', - lastName: 'رضایی', - gender: 'male', - status: 'active', - cityId: 1, - }) - ) - }) -} - -/** AuthGate consumer mobile field has no label — only the placeholder. */ -export const mobileField = (page: Page) => page.getByPlaceholder('مثال ۰۹۱۲۳۴۵۶۷۸۹') - -export const openAuthGateFromHome = async (page: Page) => { - await page.goto('/?auth=1', { waitUntil: 'domcontentloaded' }) - await expect(page.getByRole('heading', { name: 'سلام!' })).toBeVisible({ timeout: 15_000 }) - await expect(mobileField(page)).toBeVisible() -} - -export const submitMobile = async (page: Page, mobile = CONSUMER_MOBILE_PERSIAN) => { - await mobileField(page).fill(mobile) - await page.getByRole('button', { name: 'ادامه' }).click() -} - -export const fillOtpCode = async (page: Page, code = CONSUMER_OTP) => { - await page.locator('input').first().fill(code) -} - -export const completeConsumerProfileForm = async (page: Page) => { - await page.getByText('مرد', { exact: true }).click() - await page.getByPlaceholder('نام', { exact: true }).fill(' علی ') - await page.getByPlaceholder('نام خانوادگی', { exact: true }).fill(' رضایی ') - - await page.getByRole('button', { name: 'شهر' }).click() - const cityPicker = page.getByRole('dialog').filter({ hasText: 'شهرتان را انتخاب کنید' }) - - await expect(cityPicker).toBeVisible() - await cityPicker.getByRole('button', { name: 'تهران' }).click() - await cityPicker.getByRole('button', { name: 'انتخاب' }).click() - - await page.getByRole('button', { name: 'تاریخ تولد' }).click() - const birthPicker = page.getByRole('dialog').filter({ hasText: 'تاریخ تولدتان را انتخاب کنید' }) - - await expect(birthPicker).toBeVisible() - await birthPicker.getByRole('button', { name: 'انتخاب' }).click() - - await page.getByRole('button', { name: 'تکمیل ثبت‌نام' }).click() -} - -export const expectActiveSession = async (page: Page) => { - const storedUser = await page.evaluate(() => JSON.parse(localStorage.getItem('user') ?? 'null')) - - expect(storedUser).toMatchObject({ - userId: 'user-id', - role: 'user', - sessionId: 'session-id', - status: 'active', - }) - expect(storedUser.refreshToken).toBeUndefined() -} - -export const fulfillApiError = async (route: Route, code: string, message: string, status = 403) => { - await route.fulfill({ - status, - contentType: 'application/json', - body: JSON.stringify({ success: false, code, message }), - }) -} diff --git a/e2e/fixtures/consumerEventApi.ts b/e2e/fixtures/consumerEventApi.ts deleted file mode 100644 index 8c5c48f..0000000 --- a/e2e/fixtures/consumerEventApi.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Shared event payloads for consumer Playwright e2e. - * - * Next SSR fetches `NEXT_PUBLIC_API_URL` (127.0.0.1:3000 in e2e) — Playwright - * `page.route` cannot intercept that. `global-setup` serves these shapes so - * `/e/:slug` and `/e/:uuid` render instead of 404. - */ - -export const E2E_PAYMENT_EVENT_ID = '11111111-1111-4111-8111-111111111111' -export const E2E_PAYMENT_EVENT_SLUG = 'e2e-payment-event' - -export const E2E_WAITLIST_EVENT_ID = '22222222-2222-4222-8222-222222222222' -export const E2E_WAITLIST_EVENT_SLUG = 'e2e-full-event' - -export const E2E_KEEPALIVE_EVENT_ID = '33333333-3333-4333-8333-333333333333' -export const E2E_KEEPALIVE_EVENT_SLUG = 'demo-event' - -const baseEvent = (overrides: Record = {}) => ({ - id: E2E_PAYMENT_EVENT_ID, - organizerId: 'organizer-1', - categoryId: 10, - categoryName: 'ورزشی', - cityName: 'تهران', - title: 'رویداد تست پرداخت', - slug: E2E_PAYMENT_EVENT_SLUG, - shortDescription: 'توضیح کوتاه', - description: 'توضیح کامل', - startsAt: '2030-01-01T10:00:00.000Z', - endsAt: '2030-01-01T12:00:00.000Z', - provinceId: 1, - cityId: 2, - address: 'تهران', - lat: 35.7, - lng: 51.4, - isFree: false, - price: 100_000, - capacity: 20, - bookedCount: 1, - reservedCapacity: 0, - status: 'published', - isFeatured: false, - posterUrl: null, - avgRating: null, - reviewsCount: 0, - genderRestriction: null, - ageRestriction: null, - settings: { - isDiscoverable: true, - addressVisibility: 'public' as const, - generalArea: null, - }, - ...overrides, -}) - -export const e2ePaymentEvent = (title = 'رویداد تست پرداخت') => baseEvent({ title }) - -export const e2eWaitlistEvent = () => - baseEvent({ - id: E2E_WAITLIST_EVENT_ID, - slug: E2E_WAITLIST_EVENT_SLUG, - title: 'رویداد تکمیل ظرفیت', - status: 'full', - capacity: 10, - bookedCount: 10, - settings: { - isDiscoverable: true, - autoCreateGroup: true, - addressVisibility: 'public', - generalArea: null, - waitlistAutoOffer: true, - }, - }) - -export const e2eKeepAliveEvent = () => - baseEvent({ - id: E2E_KEEPALIVE_EVENT_ID, - slug: E2E_KEEPALIVE_EVENT_SLUG, - title: 'ایونت تست keep-alive', - price: 0, - isFree: true, - }) - -const landingOrganizer = { - id: 'organizer-1', - firstName: 'سارا', - lastName: 'احمدی', - avatarUrl: null, - gender: 'female' as const, - followersCount: 0, - pastEventsCount: 0, - contactLinks: [] as { channel: string; label: string; url: string }[], -} - -export const e2eEventLandingBootstrap = (event: ReturnType) => ({ - event, - category: { id: 10, name: 'ورزشی', slug: 'sports' }, - city: { id: 2, name: 'تهران' }, - media: [], - faqs: [], - organizer: landingOrganizer, - reviews: [], - reviewsTotal: 0, -}) - -export const e2eDiscoveryCities = [{ id: 1, provinceId: 8, name: 'تهران', slug: 'tehran' }] - -export const e2eEmptyHomeFeed = { - popular: [] as unknown[], - categoryPreviews: [] as unknown[], - cityPreviews: [] as unknown[], -} - -/** Resolve SSR GETs used by consumer home + event landing during Playwright. */ -export function matchConsumerE2eMockGet(pathname: string): { status: number; body: unknown } | null { - if (pathname === '/api/v1/discovery/cities') { - return { status: 200, body: { success: true, data: e2eDiscoveryCities } } - } - - if (pathname === '/api/v1/discovery/categories') { - return { status: 200, body: { success: true, data: [] } } - } - - if (pathname === '/api/v1/discovery/home-feed') { - return { status: 200, body: { success: true, data: e2eEmptyHomeFeed } } - } - - if (pathname === `/api/v1/events/by-slug/${E2E_PAYMENT_EVENT_SLUG}/bootstrap`) { - return { status: 200, body: { success: true, data: e2eEventLandingBootstrap(e2ePaymentEvent()) } } - } - - if (pathname === `/api/v1/events/by-slug/${E2E_WAITLIST_EVENT_SLUG}/bootstrap`) { - return { status: 200, body: { success: true, data: e2eEventLandingBootstrap(e2eWaitlistEvent()) } } - } - - if (pathname === `/api/v1/events/by-slug/${E2E_KEEPALIVE_EVENT_SLUG}/bootstrap`) { - return { status: 200, body: { success: true, data: e2eEventLandingBootstrap(e2eKeepAliveEvent()) } } - } - - if (pathname === `/api/v1/events/${E2E_PAYMENT_EVENT_ID}`) { - return { status: 200, body: { success: true, data: e2ePaymentEvent() } } - } - - if (pathname === `/api/v1/events/${E2E_WAITLIST_EVENT_ID}`) { - return { status: 200, body: { success: true, data: e2eWaitlistEvent() } } - } - - if (pathname === `/api/v1/events/${E2E_KEEPALIVE_EVENT_ID}`) { - return { status: 200, body: { success: true, data: e2eKeepAliveEvent() } } - } - - return null -} diff --git a/e2e/fixtures/session.ts b/e2e/fixtures/session.ts index d14e56e..aca8b3c 100644 --- a/e2e/fixtures/session.ts +++ b/e2e/fixtures/session.ts @@ -54,5 +54,3 @@ export async function authenticateAs(context: BrowserContext, page: Page, role: { token: accessToken, refreshExpiry: expiresAt, userRole: role } ) } - -export const authenticateConsumer = (context: BrowserContext, page: Page) => authenticateAs(context, page, 'user') diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index d1ba157..e9acc2a 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,79 +1,7 @@ -import { createServer, type Server, type ServerResponse } from 'node:http' - -import { - E2E_MOCK_API_HOST, - E2E_MOCK_API_PORT, - e2eBlogArticleDetail, - e2eBlogArticleSlug, - e2eBlogArticleSummary, -} from './fixtures/blogApiMock' -import { matchConsumerE2eMockGet } from './fixtures/consumerEventApi' - -declare global { - var __ghabileeE2eMockApiServer: Server | undefined -} - -function writeJson(res: ServerResponse, status: number, body: unknown) { - res.writeHead(status, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify(body)) -} - +/** + * Admin e2e no longer boots a consumer mock API. + * Full `pnpm test:e2e` talks to the real Nest API (or whatever the env points at). + */ export default async function globalSetup() { - const server = createServer((req, res) => { - const url = new URL(req.url ?? '/', `http://${E2E_MOCK_API_HOST}:${E2E_MOCK_API_PORT}`) - const { pathname } = url - - if (req.method !== 'GET') { - writeJson(res, 405, { success: false, message: 'Method not allowed' }) - - return - } - - if (pathname === '/api/v1/blog-articles') { - writeJson(res, 200, { success: true, data: [e2eBlogArticleSummary] }) - - return - } - - if (pathname === `/api/v1/blog-articles/${e2eBlogArticleSlug}`) { - writeJson(res, 200, { success: true, data: e2eBlogArticleDetail }) - - return - } - - if (pathname === `/api/v1/blog-articles/${e2eBlogArticleSlug}/related`) { - writeJson(res, 200, { success: true, data: [] }) - - return - } - - const consumerMock = matchConsumerE2eMockGet(pathname) - - if (consumerMock) { - writeJson(res, consumerMock.status, consumerMock.body) - - return - } - - writeJson(res, 404, { success: false, message: 'Not found' }) - }) - - await new Promise((resolve, reject) => { - server.once('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - // Full `pnpm test:e2e` already runs the Nest API on :3000 — use its blog seed data. - resolve() - - return - } - reject(error) - }) - server.listen(E2E_MOCK_API_PORT, E2E_MOCK_API_HOST, () => { - resolve() - }) - }) - - if (server.listening) { - globalThis.__ghabileeE2eMockApiServer = server - } + // no-op } diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts index 476d62b..bed130e 100644 --- a/e2e/global-teardown.ts +++ b/e2e/global-teardown.ts @@ -1,14 +1,3 @@ export default async function globalTeardown() { - const server = globalThis.__ghabileeE2eMockApiServer - - if (!server) return - - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) reject(error) - else resolve() - }) - }) - - globalThis.__ghabileeE2eMockApiServer = undefined + // no-op — no mock API server is started for admin e2e } diff --git a/e2e/shared/access-control.spec.ts b/e2e/shared/access-control.spec.ts index 3d8c20e..7ed7c47 100644 --- a/e2e/shared/access-control.spec.ts +++ b/e2e/shared/access-control.spec.ts @@ -16,21 +16,6 @@ const authenticate = async (context: BrowserContext, page: Page, role: 'admin' | ]) } -test('redirects a guest from a private consumer route to home with auth modal intent', async ({ page }) => { - await page.route('**/api/v1/**', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data: { items: [], meta: {} } }), - }) - ) - await page.goto('/chats') - - await expect(page).toHaveURL(/\/(\?|$)/) - await expect(page).not.toHaveURL(/\/auth/) - await expect(page.getByPlaceholder('مثال ۰۹۱۲۳۴۵۶۷۸۹')).toBeVisible() -}) - test('redirects a guest from an admin route to the admin auth page', async ({ page }) => { await page.goto('/dashboard') @@ -40,17 +25,10 @@ test('redirects a guest from an admin route to the admin auth page', async ({ pa await expect(page.getByRole('button', { name: 'ادامه' })).toBeVisible() }) -test('allows public discovery routes without a session', async ({ page }) => { - await page.route('**/api/v1/**', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data: { items: [], meta: {} } }), - }) - ) +test('root redirects guests toward auth via the dashboard gate', async ({ page }) => { const response = await page.goto('/') - await expect(page).toHaveURL('http://127.0.0.1:3102/') + await expect(page).toHaveURL(/\/auth/) await expect(page.locator('body')).toBeVisible() expect(response?.headers()['content-security-policy']).toContain("frame-ancestors 'none'") expect(response?.headers()['x-content-type-options']).toBe('nosniff') @@ -61,7 +39,7 @@ test('prevents a consumer from opening admin routes', async ({ context, page }) await authenticate(context, page, 'user') await page.goto('/dashboard') - await expect(page).toHaveURL('http://127.0.0.1:3102/') + await expect(page).toHaveURL(/\/auth/) }) test('ignores a forged userRole=admin cookie when the access JWT is a consumer', async ({ context, page }) => { @@ -98,51 +76,12 @@ test('ignores a forged userRole=admin cookie when the access JWT is a consumer', ) await page.goto('/dashboard') - await expect(page).toHaveURL('http://127.0.0.1:3102/') + await expect(page).toHaveURL(/\/auth/) }) -test('redirects an admin away from the consumer shell', async ({ context, page }) => { +test('redirects an authenticated admin from root to the dashboard', async ({ context, page }) => { await authenticate(context, page, 'admin') await page.goto('/') await expect(page).toHaveURL(/\/dashboard$/) }) - -test('forces pending consumers to complete profile in the auth modal', async ({ context, page }) => { - const accessToken = createAccessToken('user') - - await context.addCookies([ - { name: 'accessToken', value: accessToken, domain: '127.0.0.1', path: '/' }, - { name: 'userRole', value: 'user', domain: '127.0.0.1', path: '/' }, - { name: 'userStatus', value: 'pending', domain: '127.0.0.1', path: '/' }, - ]) - await page.addInitScript( - ({ token }) => { - localStorage.setItem( - 'user', - JSON.stringify({ - accessToken: token, - userId: 'user-id', - role: 'user', - sessionId: 'user-session', - AccessTokenExpireTime: Date.now() + 3_600_000, - refreshTokenExpireTime: Date.now() + 86_400_000, - status: 'pending', - }) - ) - }, - { token: accessToken } - ) - - await page.route('**/api/v1/**', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data: { items: [], meta: {} } }), - }) - ) - await page.goto('/profile') - - await expect(page).toHaveURL(/\/profile$/) - await expect(page.getByText('تکمیل اطلاعات پروفایل')).toBeVisible() -}) diff --git a/e2e/shared/accessibility.spec.ts b/e2e/shared/accessibility.spec.ts index f71a1fa..1c617cb 100644 --- a/e2e/shared/accessibility.spec.ts +++ b/e2e/shared/accessibility.spec.ts @@ -1,27 +1,7 @@ import AxeBuilder from '@axe-core/playwright' import { expect, test, type Page } from '@playwright/test' -import { authenticateAs, authenticateConsumer } from '@/e2e/fixtures/session' - -const consumerProfile = { - id: 'user-id', - mobile: '09123456789', - firstName: 'علی', - lastName: 'رضایی', - gender: 'male', - cityId: null, - bio: 'کاربر تست', - avatarUrl: null, - status: 'active', - identityStatus: 'verified', - role: 'user', -} - -const consumerViewports = [ - { name: 'mobile', width: 360, height: 800 }, - { name: 'tablet', width: 768, height: 1024 }, - { name: 'desktop', width: 1440, height: 900 }, -] as const +import { authenticateAs } from '@/e2e/fixtures/session' const expectNoSeriousViolations = async (page: Page) => { await page.waitForLoadState('domcontentloaded') @@ -47,12 +27,6 @@ test('auth route has no serious automated accessibility violations', async ({ pa await expectNoSeriousViolations(page) }) -test('public discovery route has no serious automated accessibility violations', async ({ page }) => { - await page.route('**/api/v1/**', (route) => route.fulfill({ json: { success: true, data: { items: [], meta: {} } } })) - await page.goto('/') - await expectNoSeriousViolations(page) -}) - test('admin shell has no serious automated accessibility violations', async ({ context, page }) => { await authenticateAs(context, page, 'admin') await page.route('**/api/v1/**', (route) => route.fulfill({ json: { success: true, data: {} } })) @@ -80,46 +54,3 @@ test('admin shell has no serious automated accessibility violations', async ({ c await expect(page.locator('#mainContent')).toHaveCSS('opacity', '1') await expectNoSeriousViolations(page) }) - -for (const viewport of consumerViewports) { - test(`consumer shell has no serious violations or horizontal overflow at ${viewport.name} width`, async ({ context, page }) => { - await page.setViewportSize({ width: viewport.width, height: viewport.height }) - await authenticateConsumer(context, page) - await page.route('**/api/v1/users/me', (route) => route.fulfill({ json: { success: true, data: consumerProfile } })) - await page.route('**/api/v1/users/me/wallet', (route) => - route.fulfill({ json: { success: true, data: { id: 'wallet-1', balance: 250000 } } }) - ) - await page.route('**/api/v1/organizers/me/following?**', (route) => - route.fulfill({ - json: { success: true, data: { items: [], pagination: { page: 1, pageSize: 1, totalItemsCount: 0, totalPagesCount: 0 } } }, - }) - ) - await page.route('**/api/v1/conversations/me/unread-count', (route) => - route.fulfill({ json: { success: true, data: { totalUnread: 0 } } }) - ) - await page.route('**/api/v1/notifications/me/unread-count', (route) => - route.fulfill({ json: { success: true, data: { totalUnread: 0 } } }) - ) - - await page.goto('/profile') - await expect(page.getByRole('heading', { level: 1, name: 'پروفایل من' })).toBeVisible() - await expect(page.getByRole('main')).toBeVisible() - const bottomNavigation = page.getByRole('navigation', { name: 'ناوبری پایین' }) - - await expect(bottomNavigation).toBeVisible() - - const navigationItemSizes = await bottomNavigation.getByRole('link').evaluateAll((links) => - links.map((link) => { - const bounds = link.getBoundingClientRect() - - return { height: bounds.height, width: bounds.width } - }) - ) - - const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth) - - expect(hasHorizontalOverflow).toBe(false) - expect(navigationItemSizes.every(({ height, width }) => height >= 44 && width >= 44)).toBe(true) - await expectNoSeriousViolations(page) - }) -} diff --git a/e2e/shared/public-pages.spec.ts b/e2e/shared/public-pages.spec.ts deleted file mode 100644 index f54946d..0000000 --- a/e2e/shared/public-pages.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from '@playwright/test' - -const contentPages = [ - ['/about', 'درباره قبیله'], - ['/faq', 'سؤالات متداول'], - ['/terms', 'شرایط استفاده'], - ['/privacy', 'حریم خصوصی'], - ['/refund-policy', 'بازگشت وجه'], - ['/become-a-host', 'برگزارکننده شو'], - ['/host-guide', 'میزبان'], -] as const - -for (const [path, expectedText] of contentPages) { - test(`${path} renders its public content`, async ({ page }) => { - const response = await page.goto(path) - - expect(response?.ok()).toBe(true) - await expect(page.getByRole('main')).toContainText(expectedText) - }) -} - -test('about uses a search title separate from the marketing heading', async ({ page }) => { - await page.goto('/about') - await expect(page).toHaveTitle(/درباره قبیله/) - await expect(page.getByRole('heading', { level: 1 })).toContainText('ما هنوز هم دور چیزهایی که دوست داریم جمع می‌شیم') - await expect(page.getByRole('heading', { level: 2, name: /هزاران ساله/ })).toBeVisible() - await expect(page.getByRole('heading', { level: 2, name: /همین کارو می‌کنیم/ })).toBeVisible() -}) - -test('faq exposes questions as headings and FAQ structured data', async ({ page }) => { - await page.goto('/faq') - await expect(page).toHaveTitle(/سؤالات متداول/) - await expect(page.getByRole('heading', { level: 2, name: 'چطور در یک رویداد ثبت‌نام کنم؟' })).toBeVisible() - - const jsonLd = await page.locator('script[type="application/ld+json"]').allTextContents() - - expect(jsonLd.some((block) => block.includes('"FAQPage"'))).toBe(true) -}) - -test('blog listing opens an article', async ({ page }) => { - await page.goto('/blog') - await expect(page.getByRole('heading', { name: /راهنمای تجربه‌های بهتر/ })).toBeVisible() - - const firstArticleLink = page.locator('main article').first().getByRole('link') - const articleTitle = await firstArticleLink.locator('h2').innerText() - - await expect(firstArticleLink).toBeVisible() - await firstArticleLink.click() - await expect(page).toHaveURL(/\/blog\/[^/]+$/, { timeout: 15_000 }) - await expect(page.getByRole('heading', { level: 1 })).toContainText(articleTitle.trim()) -}) - -test('contact form renders the public inbox fields', async ({ page }) => { - await page.goto('/contact') - await expect(page.getByLabel('نام و نام خانوادگی')).toBeVisible() - await expect(page.getByLabel('شماره تماس')).toBeVisible() - await expect(page.getByLabel('موضوع')).toBeVisible() - await expect(page.getByLabel('پیام شما')).toBeVisible() - await expect(page.getByRole('button', { name: 'ارسال پیام' })).toBeVisible() -}) - -test('unknown routes bounce guests to home with auth modal, not admin /auth', async ({ page }) => { - await page.route('**/api/v1/**', (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true, data: { items: [], meta: {} } }), - }) - ) - await page.goto('/not-a-real-content-page') - - await expect(page).toHaveURL(/\/(\?|$)/) - await expect(page).not.toHaveURL(/\/auth/) - await expect(page.getByLabel('شماره موبایل')).toBeVisible() -}) diff --git a/features/events/edit/mapEventToEditWizardData.ts b/features/events/edit/mapEventToEditWizardData.ts deleted file mode 100644 index bb20945..0000000 --- a/features/events/edit/mapEventToEditWizardData.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { EventWizardFormData } from '@/components/events/create/consumer/types' -import { createClientId } from '@/lib/createClientId' -import type { EventAgeRestriction, EventGenderRestriction } from '@/lib/eventAudience' -import type { CreatedEvent, EventRevisionPayload } from '@/services/events' -import type { EventFaq, EventMedia } from '@/services/eventDetail' - -function splitIsoToDateAndMinutes(iso: string): { date: string; minutes: number } { - const d = new Date(iso) - - return { - date: d.toISOString(), - minutes: d.getHours() * 60 + d.getMinutes(), - } -} - -function mapMediaToStaged(media: EventMedia[]): EventWizardFormData['media'] { - return media - .filter((item) => item.mediaType === 'image') - .map((item, index) => ({ - // نگه داشتن id سرور برای sync create/update/delete در ویرایش non-live - id: item.id, - url: item.url, - isPoster: item.isPoster, - isSquarePoster: Boolean(item.isSquarePoster), - sortOrder: item.sortOrder ?? index, - })) -} - -function mapFaqsToStaged(faqs: EventFaq[]): EventWizardFormData['faqs'] { - return faqs.map((faq) => ({ - id: faq.id, - question: faq.question, - answer: faq.answer, - })) -} - -/** - * Maps an existing event (plus media/FAQs) into edit-wizard form values. - * Unlike clone, keeps the real title (no clone suffix). - */ -export function mapEventToEditWizardData(event: CreatedEvent, media: EventMedia[], faqs: EventFaq[]): EventWizardFormData { - const start = splitIsoToDateAndMinutes(String(event.startsAt)) - const end = splitIsoToDateAndMinutes(String(event.endsAt)) - - return { - title: event.title, - slug: event.slug, - categoryId: String(event.categoryId), - shortDescription: event.shortDescription ?? '', - description: event.description ?? '', - media: mapMediaToStaged(media), - startDate: start.date, - startTime: start.minutes, - endDate: end.date, - endTime: end.minutes, - provinceId: String(event.provinceId), - cityId: String(event.cityId), - 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, - 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: mapFaqsToStaged(faqs), - } -} - -/** - * Prefer a pending revision snapshot when hydrating the edit wizard. - * Falls back to live media/FAQs when the revision omits them. - */ -export function mapRevisionPayloadToEditWizardData( - event: CreatedEvent, - payload: EventRevisionPayload, - fallbackMedia: EventMedia[], - fallbackFaqs: EventFaq[] -): EventWizardFormData { - const base = mapEventToEditWizardData(event, fallbackMedia, fallbackFaqs) - const startsAt = payload.startsAt ? String(payload.startsAt) : String(event.startsAt) - const endsAt = payload.endsAt ? String(payload.endsAt) : String(event.endsAt) - const start = splitIsoToDateAndMinutes(startsAt) - const end = splitIsoToDateAndMinutes(endsAt) - - const mediaFromPayload = Array.isArray(payload.media) - ? payload.media.map((item, index) => ({ - id: createClientId(), - url: item.url, - isPoster: Boolean(item.isPoster), - isSquarePoster: Boolean(item.isSquarePoster), - sortOrder: item.sortOrder ?? index, - })) - : base.media - - const faqsFromPayload = Array.isArray(payload.faqs) - ? payload.faqs.map((faq) => ({ - id: createClientId(), - question: faq.question, - answer: faq.answer, - })) - : base.faqs - - const settings = payload.settings ?? {} - - return { - ...base, - title: payload.title ?? base.title, - slug: payload.slug ?? base.slug, - categoryId: payload.categoryId != null ? String(payload.categoryId) : base.categoryId, - shortDescription: payload.shortDescription ?? base.shortDescription, - description: payload.description ?? base.description, - media: mediaFromPayload, - startDate: start.date, - startTime: start.minutes, - endDate: end.date, - endTime: end.minutes, - provinceId: payload.provinceId != null ? String(payload.provinceId) : base.provinceId, - cityId: payload.cityId != null ? String(payload.cityId) : base.cityId, - address: payload.address ?? base.address, - lat: payload.lat ?? base.lat, - lng: payload.lng ?? base.lng, - isFree: payload.isFree ?? base.isFree, - price: payload.price ?? base.price, - capacity: payload.capacity ?? base.capacity, - genderRestriction: (payload.genderRestriction as EventGenderRestriction | undefined) ?? base.genderRestriction, - ageRestriction: (payload.ageRestriction as EventAgeRestriction | undefined) ?? base.ageRestriction, - cancellationFeePercent: payload.cancellationFeePercent ?? base.cancellationFeePercent, - isDiscoverable: settings.isDiscoverable ?? base.isDiscoverable, - autoCreateGroup: settings.autoCreateGroup ?? base.autoCreateGroup, - addressVisibility: settings.addressVisibility ?? base.addressVisibility, - generalArea: settings.generalArea ?? base.generalArea, - waitlistAutoOffer: settings.waitlistAutoOffer ?? base.waitlistAutoOffer, - sendReviewRequestSms: settings.sendReviewRequestSms ?? base.sendReviewRequestSms, - faqs: faqsFromPayload, - } -} diff --git a/helpers/index.ts b/helpers/index.ts index d36109b..3e22b96 100644 --- a/helpers/index.ts +++ b/helpers/index.ts @@ -423,7 +423,7 @@ const SERVICE_ERROR_MESSAGES: Record = { * * **When to use it:** in a `catch` block for services that throw raw axios errors instead * of returning a `ServiceResult` (e.g. the camelCase event-domain services — - * `events.ts`, `eventManagement.ts`, `eventBookmarks.ts`, `eventDetail.ts`, `discovery.ts`, + * `events.ts`, `eventManagement.ts`, `eventDetail.ts`, `discovery.ts`, * `geography.ts`, `mapirReverseGeocode.ts`). It accepts the same shape those catch blocks * already narrow to — `(err as { response?: { data?: unknown } })?.response?.data` — so it * is a drop-in replacement for the hand-copied diff --git a/lib/googleMapsNavigation.test.ts b/lib/googleMapsNavigation.test.ts deleted file mode 100644 index 086175f..0000000 --- a/lib/googleMapsNavigation.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { getGoogleMapsDirectionsUrl, getGoogleMapsPlaceUrl } from '@/lib/googleMapsNavigation' - -describe('googleMapsNavigation', () => { - it('builds a driving-directions URL with origin and destination coordinates', () => { - expect(getGoogleMapsDirectionsUrl({ lat: 35.7, lng: 51.4 }, { lat: 35.8, lng: 51.5 })).toBe( - 'https://www.google.com/maps/dir/?api=1&origin=35.7%2C51.4&destination=35.8%2C51.5&travelmode=driving' - ) - }) - - it('builds a destination fallback URL', () => { - expect(getGoogleMapsPlaceUrl({ lat: 35.7, lng: 51.4 })).toBe('https://www.google.com/maps/search/?api=1&query=35.7%2C51.4') - }) -}) diff --git a/lib/googleMapsNavigation.ts b/lib/googleMapsNavigation.ts deleted file mode 100644 index 556a278..0000000 --- a/lib/googleMapsNavigation.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MapCoordinates } from '@/lib/neshanNavigation' - -function coordinatePair({ lat, lng }: MapCoordinates) { - return `${lat},${lng}` -} - -/** Opens the event pin in Google Maps when the visitor's location is unavailable. */ -export function getGoogleMapsPlaceUrl(destination: MapCoordinates) { - const url = new URL('https://www.google.com/maps/search/') - - url.searchParams.set('api', '1') - url.searchParams.set('query', coordinatePair(destination)) - - return url.toString() -} - -/** Opens driving directions in Google Maps from a known origin to an event destination. */ -export function getGoogleMapsDirectionsUrl(origin: MapCoordinates, destination: MapCoordinates) { - const url = new URL('https://www.google.com/maps/dir/') - - url.searchParams.set('api', '1') - url.searchParams.set('origin', coordinatePair(origin)) - url.searchParams.set('destination', coordinatePair(destination)) - url.searchParams.set('travelmode', 'driving') - - return url.toString() -} diff --git a/lib/neshanNavigation.test.ts b/lib/neshanNavigation.test.ts deleted file mode 100644 index 0b9c48e..0000000 --- a/lib/neshanNavigation.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { getNeshanDirectionsUrl, getNeshanPlaceUrl, hasMapCoordinates } from '@/lib/neshanNavigation' - -describe('neshanNavigation', () => { - it('builds a driving-directions URL with origin and destination coordinates', () => { - expect(getNeshanDirectionsUrl({ lat: 35.7, lng: 51.4 }, { lat: 35.8, lng: 51.5 })).toBe( - 'https://nshn.ir/maps?origin=35.7%2C51.4&destination=35.8%2C51.5&type=drive' - ) - }) - - it('builds a destination fallback URL', () => { - expect(getNeshanPlaceUrl({ lat: 35.7, lng: 51.4 })).toBe('https://nshn.ir/?lat=35.7&lng=51.4') - }) - - it('accepts only valid latitude and longitude pairs', () => { - expect(hasMapCoordinates(35.7, 51.4)).toBe(true) - expect(hasMapCoordinates(91, 51.4)).toBe(false) - expect(hasMapCoordinates(35.7, null)).toBe(false) - }) -}) diff --git a/lib/neshanNavigation.ts b/lib/neshanNavigation.ts deleted file mode 100644 index beabd4b..0000000 --- a/lib/neshanNavigation.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface MapCoordinates { - lat: number - lng: number -} - -const isLatitude = (value: number) => Number.isFinite(value) && value >= -90 && value <= 90 -const isLongitude = (value: number) => Number.isFinite(value) && value >= -180 && value <= 180 - -export function hasMapCoordinates(lat: number | null | undefined, lng: number | null | undefined) { - return typeof lat === 'number' && typeof lng === 'number' && isLatitude(lat) && isLongitude(lng) -} - -export function toMapCoordinates(lat: number | null | undefined, lng: number | null | undefined): MapCoordinates | null { - if (typeof lat !== 'number' || typeof lng !== 'number' || !hasMapCoordinates(lat, lng)) return null - - return { lat, lng } -} - -function coordinatePair({ lat, lng }: MapCoordinates) { - return `${lat},${lng}` -} - -/** Opens the event pin in Neshan when the visitor's location is unavailable. */ -export function getNeshanPlaceUrl(destination: MapCoordinates) { - const url = new URL('https://nshn.ir/') - - url.searchParams.set('lat', String(destination.lat)) - url.searchParams.set('lng', String(destination.lng)) - - return url.toString() -} - -/** Opens driving directions in Neshan from a known origin to an event destination. */ -export function getNeshanDirectionsUrl(origin: MapCoordinates, destination: MapCoordinates) { - const url = new URL('https://nshn.ir/maps') - - url.searchParams.set('origin', coordinatePair(origin)) - url.searchParams.set('destination', coordinatePair(destination)) - url.searchParams.set('type', 'drive') - - return url.toString() -} diff --git a/lib/seo/JsonLd.test.ts b/lib/seo/JsonLd.test.ts deleted file mode 100644 index 232a95d..0000000 --- a/lib/seo/JsonLd.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { ORGANIZATION_ID, WEBSITE_ID, eventSchema, faqPage, organizationSchema, webSiteSchema } from '@/lib/seo/JsonLd' - -const eventSchemaBase = { - name: 'رویداد نمونه', - description: null, - startDate: '2026-08-01T10:00:00Z', - endDate: '2026-08-01T12:00:00Z', - url: 'https://ghabilee.ir/e/sample', - isFree: false, - price: 250_000, -} - -describe('SEO structured data', () => { - it('converts stored toman prices to rial for IRR offers', () => { - const schema = eventSchema({ - ...eventSchemaBase, - address: 'خیابان ولیعصر', - cityName: 'تهران', - }) - - expect(schema.offers).toMatchObject({ price: 2_500_000, priceCurrency: 'IRR' }) - expect(schema).toMatchObject({ url: eventSchemaBase.url }) - }) - - it('emits PostalAddress for a public street address', () => { - const schema = eventSchema({ - ...eventSchemaBase, - address: 'خیابان ولیعصر، پلاک ۱۲', - cityName: 'تهران', - }) - - expect(schema.location).toEqual({ - '@type': 'Place', - name: 'تهران', - address: { - '@type': 'PostalAddress', - streetAddress: 'خیابان ولیعصر، پلاک ۱۲', - addressLocality: 'تهران', - addressCountry: 'IR', - }, - }) - }) - - it('uses generalArea when the exact address is hidden from crawlers', () => { - const schema = eventSchema({ - ...eventSchemaBase, - address: null, - generalArea: 'خیابان هفت‌تیر', - cityName: 'مشهد', - }) - - expect(schema.location.address).toEqual({ - '@type': 'PostalAddress', - streetAddress: 'خیابان هفت‌تیر', - addressLocality: 'مشهد', - addressCountry: 'IR', - }) - expect(JSON.stringify(schema.location)).not.toContain('null') - }) - - it('still emits addressLocality when only the city is public', () => { - const schema = eventSchema({ - ...eventSchemaBase, - address: null, - cityName: 'اصفهان', - }) - - expect(schema.location.address).toEqual({ - '@type': 'PostalAddress', - addressLocality: 'اصفهان', - addressCountry: 'IR', - }) - }) - - it('treats blank address strings as missing and does not leak them', () => { - const schema = eventSchema({ - ...eventSchemaBase, - address: ' ', - generalArea: 'محدوده ونک', - cityName: 'تهران', - }) - - expect(schema.location.address.streetAddress).toBe('محدوده ونک') - }) - - it('emits FAQPage entities for evergreen landing content', () => { - const schema = faqPage([{ question: 'چطور ثبت‌نام کنم؟', answer: 'از صفحه رویداد.' }]) - - expect(schema).toMatchObject({ - '@type': 'FAQPage', - mainEntity: [{ '@type': 'Question', name: 'چطور ثبت‌نام کنم؟' }], - }) - }) - - it('uses stable IDs to connect the website and its organization', () => { - const organization = organizationSchema({ - url: 'https://ghabilee.ir', - logo: 'https://ghabilee.ir/logo.svg', - telephone: '+985100000000', - address: { locality: 'مشهد', streetAddress: 'نمونه' }, - }) - - expect(organization).toMatchObject({ - '@id': ORGANIZATION_ID, - alternateName: 'Ghabilee', - sameAs: ['https://www.instagram.com/Ghabilee_support/', 'https://t.me/Ghabilee_support'], - contactPoint: { - '@type': 'ContactPoint', - telephone: '+985100000000', - contactType: 'customer support', - areaServed: 'IR', - }, - }) - expect(webSiteSchema('https://ghabilee.ir')).toMatchObject({ - '@id': WEBSITE_ID, - publisher: { '@id': ORGANIZATION_ID }, - }) - }) -}) diff --git a/lib/seo/JsonLd.tsx b/lib/seo/JsonLd.tsx deleted file mode 100644 index a8351e0..0000000 --- a/lib/seo/JsonLd.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { SITE_SOCIAL_SAME_AS } from '@/constants/contact' - -/** - * Renders a `