feat: add layout components for various admin dashboard pages

- Introduced layout components for multiple admin dashboard sections including events, audit logs, bank accounts, blog articles, bookings, chat oversight, cities, contact messages, dashboard, discount codes, event categories, guest lists, identity verifications, manage events, manual notifications, notification rules, payments, provinces, push deliveries, reviews, settlements, sms messages, support tickets, user reports, and users.
- Updated metadata for the auth layout to include the admin page title.
- Modified branch protection documentation to clarify deployment notifications.
This commit is contained in:
alisaza 2026-09-05 14:29:43 +03:30
parent e1eaf5eff5
commit f9a9afc697
155 changed files with 507 additions and 10233 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = () => <AdminEventDetail />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<SessionProviders>
<section className="consumer-mobile-viewport relative min-h-[100dvh] overflow-hidden bg-background-primary">
<section className="relative min-h-[100dvh] w-full overflow-hidden bg-background-primary">
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
@ -30,7 +32,7 @@ const AuthLayout = ({ children }: { children: React.ReactNode }) => {
className="absolute inset-0 bg-black/20 backdrop-blur-md"
/>
<div className="relative z-10 flex min-h-[100dvh] items-center justify-center p-4">
<div className="relative z-10 flex min-h-[100dvh] w-full items-center justify-center p-4">
<div className="flex w-full max-w-md flex-col rounded-2xl bg-white/90 p-4 backdrop-blur-sm">
<div className="flex min-h-[400px] flex-1 flex-col items-center justify-center">
<Image

View File

@ -10,7 +10,7 @@ import Input from '@/components/formElements/Input'
import ConsumerInput from '@/components/consumer/ConsumerInput'
import { texts, format } from '@/texts'
import { useAuthFlow } from '@/features/auth/useAuthFlow'
import { useDiscoveryCitiesQuery } from '@/queries/consumer/useWalletAndBookmarkQueries'
import { useDiscoveryCitiesQuery } from '@/queries/consumer/useDiscoveryCitiesQuery'
import { showFormValidationToast } from '@/lib/formValidationToast'
import { OTP_LENGTH } from '@/validation/auth'

View File

@ -3,7 +3,6 @@
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { CONSUMER_ROUTES } from '@/constants/routes'
import useAuth from '@/hooks/useAuth'
const AdminRoleGuard = ({ children }: { children: React.ReactNode }) => {
@ -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])

View File

@ -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<HTMLDivElement>(null)
const { user } = useAuth()
const userId = user?.userId
const [step, setStep] = useState(1)
const [data, setData] = useState<EventWizardFormData>(INITIAL_WIZARD_DATA)
const [isDraftHydrated, setIsDraftHydrated] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [createdEventId, setCreatedEventId] = useState<string | null>(null)
const [previousAttendees, setPreviousAttendees] = useState<PreviousAttendee[]>([])
const [showPostCreateInvite, setShowPostCreateInvite] = useState(false)
useEffect(() => {
if (!userId || hasTrackedStartRef.current) return
hasTrackedStartRef.current = true
trackAnalyticsEvent(ANALYTICS_EVENTS.EVENT_CREATE_STARTED, {
creation_source: 'consumer',
is_clone: Boolean(cloneFromIdRef.current),
})
}, [userId])
const categoriesQuery = usePublicCategoriesQuery()
const categories = useMemo(() => categoriesQuery.data?.items ?? [], [categoriesQuery.data])
const [cities, setCities] = useState<City[]>([])
const [hostCityName, setHostCityName] = useState<string | undefined>()
useEffect(() => {
if (!userId) return
let cancelled = false
const cloneFromId = cloneFromIdRef.current
const resolveHostCity = async (): Promise<HostCityPrefill | null> => {
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<EventWizardFormData>(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<EventWizardFormData>) => {
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 (
<div className="min-h-0 flex-1 overflow-y-auto">
<DetailSkeleton />
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<div className="shrink-0">
<ConsumerWizardStepper currentStep={step} />
</div>
<div
ref={stepScrollRef}
className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain pt-4"
>
{step === 1 && (
<Step1BasicInfo
data={data}
onBindSubmit={bindStepSubmit}
onChange={patchData}
onNext={(patch) => {
patchData(patch)
setStep(2)
}}
/>
)}
{step === 2 && (
<Step2ScheduleLocation
data={data}
onBindSubmit={bindStepSubmit}
onChange={patchData}
onNext={(patch) => {
patchData(patch)
setStep(3)
}}
/>
)}
{step === 3 && (
<Step3PricingCapacity
data={data}
onBindSubmit={bindStepSubmit}
onChange={patchData}
onNext={(patch) => {
patchData(patch)
setStep(4)
}}
/>
)}
{step === 4 && (
<Step4FaqPreview
categoryName={categoryName}
cityName={cityName}
data={data}
onBindSubmit={bindStepSubmit}
onChange={patchData}
onSaveDraft={() => void handleSaveDraft()}
/>
)}
</div>
<div className="shrink-0">
<ConsumerActionButtons
cancelIcon={step === 1 ? <CloseLinearIcon className="size-3" /> : <AngleLeftIcon className="size-4" />}
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}
/>
</div>
{createdEventId ? (
<Step5GuestList
attendees={previousAttendees}
eventId={createdEventId}
isOpen={showPostCreateInvite}
onFinish={() => {
router.push(finishRoute)
}}
onOpenChange={setShowPostCreateInvite}
/>
) : null}
</div>
)
}

View File

@ -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 (
<nav
aria-label={texts.events.wizardStepsAria}
className={cn('mx-auto w-full rounded-consumer-modal bg-white p-4', className)}
>
<div className="grid grid-cols-[40px_1fr_40px] items-center">
<CalendarFillIcon className="size-10" />
<h2 className="text-center text-base font-medium text-black">{texts.events.wizardProgressTitle}</h2>
<Link
aria-label={texts.identity.backToMyEvents}
className="consumer-focus-ring flex size-10 items-center justify-center rounded-full text-tertiary-900"
href={CONSUMER_ROUTES.MY_EVENTS}
>
<HomeFillIcon />
</Link>
</div>
<div className="relative mt-[18px]">
<span
aria-hidden
className="absolute inset-x-[12.5%] top-2 h-0.5 bg-secondary-40"
/>
<span
aria-hidden
className="absolute top-2 h-0.5 bg-primary transition-[width] duration-300"
style={{ insetInlineStart: '12.5%', width: `${progress}%` }}
/>
<ol className="grid grid-cols-4">
{STEPS.map((label, index) => {
const step = index + 1
const isReached = step <= currentStep
return (
<li
key={label}
aria-current={step === currentStep ? 'step' : undefined}
className="relative z-10 flex min-w-0 flex-col items-center"
>
<span className={cn('size-4 rounded-full', isReached ? 'bg-primary' : 'bg-secondary-40')} />
<span
className={cn(
'mt-1 w-full truncate px-0.5 text-center text-2xs leading-4',
isReached ? 'text-primary' : 'text-secondary-30'
)}
>
{label}
</span>
</li>
)
})}
</ol>
</div>
</nav>
)
}
export default ConsumerWizardStepper

View File

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

View File

@ -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<EventWizardFormData>) => void
onNext: (patch: Partial<EventWizardFormData>) => 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<EventWizardStep1Values>({
resolver: zodResolver(EventWizardStep1Validation),
defaultValues: {
title: data.title,
slug: data.slug,
categoryId: data.categoryId,
shortDescription: data.shortDescription,
description: data.description,
},
})
const followTitleRef = useRef(shouldAutogenerateEventSlug(data.slug, data.title))
const lastAutoSlugRef = useRef(data.slug || slugifyEventTitle(data.title))
const title = form.watch('title')
const slug = form.watch('slug')
useEffect(() => {
if (categoriesQuery.isError) {
addToast({ title: texts.events.categoriesLoadFailed, color: 'danger' })
}
}, [categoriesQuery.isError])
useEffect(() => {
if (!followTitleRef.current) return
const next = slugifyEventTitle(title ?? '')
lastAutoSlugRef.current = next
if (form.getValues('slug') !== next) {
form.setValue('slug', next)
}
}, [form, title])
useEffect(() => {
const subscription = form.watch((values, info) => {
if (info.name === 'slug' && (values.slug ?? '') !== lastAutoSlugRef.current) {
followTitleRef.current = false
}
onChange({
title: values.title ?? '',
slug: values.slug ?? '',
categoryId: values.categoryId ?? '',
shortDescription: values.shortDescription ?? '',
description: values.description ?? '',
})
})
return () => {
subscription.unsubscribe()
}
}, [form, onChange])
const handleSubmit = form.handleSubmit((values) => {
if (!hasRequiredPosters(data.media)) {
addToast({
title: texts.events.postersRequiredTitle,
description: texts.events.postersRequiredShort,
color: 'danger',
})
return
}
onNext({
title: values.title,
slug: values.slug,
categoryId: values.categoryId,
shortDescription: values.shortDescription ?? '',
description: values.description ?? '',
media: data.media,
})
}, showFormValidationToast)
useEffect(() => {
onBindSubmit(() => {
void handleSubmit()
})
return () => {
onBindSubmit(null)
}
}, [handleSubmit, onBindSubmit])
return (
<FormProvider {...form}>
<form
className="consumer-surface flex flex-col gap-4 p-4"
onSubmit={(event) => {
event.preventDefault()
void handleSubmit()
}}
>
<ConsumerInput
generalType="input"
label={texts.events.eventTitleLabel}
maxValue={200}
name="title"
/>
<ConsumerInput
generalType="select"
label={texts.common.category}
name="categoryId"
placeholder={texts.events.categoryPlaceholder}
selectKey="id"
selectOptions={categoryOptions}
selectValue="name"
/>
<EventMediaGalleryUploader
items={data.media}
onChange={(media) => {
onChange({ media })
}}
/>
<ConsumerInput
generalType="textarea"
label={texts.events.shortDescriptionLabel}
name="shortDescription"
textAreaMinRows={2}
/>
<ConsumerInput
generalType="textarea"
label={texts.events.fullDescriptionLabel}
name="description"
textAreaMinRows={5}
/>
<ConsumerInput
description={slug ? format(texts.events.slugPublicLink, { slug }) : texts.events.slugHintFromTitle}
direction="ltr"
generalType="input"
label={texts.events.slugLabel}
maxValue={220}
name="slug"
/>
</form>
</FormProvider>
)
}

View File

@ -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<EventWizardFormData>) => void
onNext: (patch: Partial<EventWizardFormData>) => void
}
export default function Step2ScheduleLocation({ data, locks, onBindSubmit, onChange, onNext }: Step2ScheduleLocationProps) {
const [errors, setErrors] = useState<ConsumerEventWizardStep2FieldErrors>({})
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<EventWizardFormData>) => {
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 (
<form
className="consumer-surface flex flex-col gap-4 p-4"
onSubmit={(event) => {
event.preventDefault()
handleSubmit()
}}
>
{showLockNotice ? <InlineNotice>{locks?.notice}</InlineNotice> : null}
<div className="grid grid-cols-2 gap-2">
<div className="col-span-2">
<span className="label mb-1 block text-sm font-medium !text-secondary-20">{texts.events.scheduleSectionTitle}</span>
<span className="label mb-1 block text-sm font-normal !text-secondary-20">{texts.events.scheduleSectionDescription}</span>
</div>
<EventScheduleFieldControl
disabled={scheduleLocked}
errorMessage={errors.startDate}
kind="eventDatePicker"
label={texts.events.startsAtDateLabel}
minDate={todayIso}
triggerStyle="consumer"
value={data.startDate}
variant="flat"
onValueChange={(value) => {
if (scheduleLocked) return
patch({ startDate: String(value) })
}}
/>
<EventScheduleFieldControl
disabled={scheduleLocked}
errorMessage={errors.startTime}
kind="eventTimePicker"
label={texts.events.startsAtTimeLabel}
minValue={startMinimumTime}
triggerStyle="consumer"
value={data.startTime}
variant="flat"
onValueChange={(value) => {
if (scheduleLocked) return
patch({ startTime: Number(value) })
}}
/>
<EventScheduleFieldControl
disabled={scheduleLocked}
errorMessage={errors.endDate}
kind="eventDatePicker"
label={texts.events.endsAtDateLabel}
minDate={data.startDate || todayIso}
triggerStyle="consumer"
value={data.endDate}
variant="flat"
onValueChange={(value) => {
if (scheduleLocked) return
patch({ endDate: String(value) })
}}
/>
<EventScheduleFieldControl
disabled={scheduleLocked}
errorMessage={errors.endTime}
kind="eventTimePicker"
label={texts.events.endsAtTimeLabel}
minValue={endMinimumTime}
triggerStyle="consumer"
value={data.endTime}
variant="flat"
onValueChange={(value) => {
if (scheduleLocked) return
patch({ endTime: Number(value) })
}}
/>
</div>
<div className="flex flex-col gap-1">
<ConsumerInput
description={texts.events.addressHostContactHint}
disabled={locationLocked}
generalType="textarea"
label={texts.events.address}
name="address"
placeholder={texts.events.addressPlaceholder}
textAreaMinRows={3}
value={data.address}
onValueChange={(value) => {
if (locationLocked) return
patch({ address: typeof value === 'string' ? value : '' })
}}
/>
{errors.address ? <p className="text-sm text-fourth-900">{errors.address}</p> : null}
</div>
<MapLocationPicker
disabled={locationLocked}
displayAddress={data.address}
lat={data.lat}
lng={data.lng}
mode="modal"
onAddressResolved={(address) => {
if (locationLocked) return
patch({ address })
}}
onChange={({ lat, lng }) => {
if (locationLocked) return
patch({ lat, lng })
}}
/>
<ConsumerInput
description={texts.events.addressVisibilitySectionDescription}
disabled={locationLocked}
generalType="checkbox"
label={texts.events.addressVisibilityAttendeesOnly}
name="addressVisibilityAttendeesOnly"
value={data.addressVisibility === 'attendees_only'}
onValueChange={(checked) => {
if (locationLocked) return
patch({
addressVisibility: checked ? 'attendees_only' : 'public',
generalArea: checked ? data.generalArea : '',
})
}}
/>
{data.addressVisibility === 'attendees_only' ? (
<div className="flex flex-col gap-1">
<ConsumerInput
disabled={locationLocked}
generalType="textarea"
label={texts.events.generalAreaLabel}
name="generalArea"
placeholder={texts.events.generalAreaPlaceholder}
textAreaMinRows={2}
value={data.generalArea}
onValueChange={(value) => {
if (locationLocked) return
patch({ generalArea: typeof value === 'string' ? value : '' })
}}
/>
{errors.generalArea ? <p className="text-sm text-fourth-900">{errors.generalArea}</p> : null}
</div>
) : null}
</form>
)
}

View File

@ -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<EventWizardFormData>) => void
onNext: (patch: Partial<EventWizardFormData>) => 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<EventWizardStep3Values>({
resolver: zodResolver(EventWizardStep3Validation),
defaultValues: {
isFree: data.isFree,
price: data.price,
capacity: data.capacity,
reservedCapacity: data.reservedCapacity,
genderRestriction: data.genderRestriction,
ageRestriction: data.ageRestriction,
cancellationFeePercent: data.cancellationFeePercent,
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 (
<FormProvider {...form}>
<form
className="consumer-surface flex flex-col gap-4 p-4"
onSubmit={(event) => {
event.preventDefault()
void handleSubmit()
}}
>
{showLockNotice ? <InlineNotice>{locks?.notice}</InlineNotice> : null}
<div className="col-span-2">
<span className="label mb-1 block text-sm font-medium text-secondary-30 font-semibold">ایجاد بلیط</span>
<span className="label mb-1 block text-xs font-normal text-secondary-30">
نوع ثبتنام را انتخاب کن؛ برای رویداد پولی، مبلغ نهایی بلیط را وارد کن.
</span>
</div>
<TicketTypeSelector
disabled={pricingLocked}
isFree={isFree}
onChange={(nextIsFree) => {
if (pricingLocked) return
form.setValue('isFree', nextIsFree, { shouldDirty: true, shouldValidate: true })
}}
/>
{!isFree ? (
<ConsumerInput
disabled={pricingLocked}
formatOptions={{ style: 'decimal' }}
generalType="numberInput"
label={texts.events.pricePerTicketLabel}
minValue={0}
name="price"
/>
) : null}
<ConsumerInput
description={(locks?.minCapacity ?? 1) > 1 ? format(texts.events.capacityMinOccupiedHint, { min: minCapacity }) : undefined}
generalType="numberInput"
label={texts.events.capacity}
minValue={minCapacity}
name="capacity"
/>
<ConsumerInput
description={locks != null ? texts.events.reservedCapacityEditLockedHint : texts.events.reservedCapacityWizardHint}
disabled={locks != null}
generalType="numberInput"
label={texts.events.reservedCapacityLabel}
minValue={0}
name="reservedCapacity"
/>
<div className="col-span-2">
<span className="label mb-1 block text-sm font-medium text-secondary-30 font-semibold">شرکت کنندگان</span>
<span className="label mb-1 block text-xs font-normal text-secondary-30">
اگر رویداد برای همه نیست، محدودیت جنسیت یا سن را انتخاب کن. پیشفرض آزاد است.
</span>
</div>
<AudienceRestrictionSelector
ageRestriction={form.watch('ageRestriction')}
disabled={audienceLocked}
genderRestriction={form.watch('genderRestriction')}
onAgeRestrictionChange={(ageRestriction) => {
if (audienceLocked) return
form.setValue('ageRestriction', ageRestriction, { shouldDirty: true, shouldValidate: true })
}}
onGenderRestrictionChange={(genderRestriction) => {
if (audienceLocked) return
form.setValue('genderRestriction', genderRestriction, { shouldDirty: true, shouldValidate: true })
}}
/>
<ConsumerInput
disabled={cancellationLocked}
generalType="numberInput"
label={texts.events.cancellationFeeLabel}
maxValue={100}
minValue={0}
name="cancellationFeePercent"
/>
<details className="group rounded-2xl border border-consumer-border bg-consumer-surface-muted p-4">
<summary className="cursor-pointer list-none text-sm font-bold text-consumer-text marker:hidden">
{texts.events.moreSettings}
</summary>
<div className="mt-4 flex flex-col gap-4 border-t border-consumer-border pt-4">
<ConsumerInput
disabled={behavioralLocked}
generalType="switch"
label={texts.events.discoverableLabel}
name="isDiscoverable"
/>
<ConsumerInput
disabled={behavioralLocked}
generalType="switch"
label={texts.events.autoCreateGroupLabel}
name="autoCreateGroup"
/>
<ConsumerInput
disabled={behavioralLocked}
generalType="switch"
label={texts.events.waitlistAutoOfferLabel}
name="waitlistAutoOffer"
/>
<ConsumerInput
disabled={behavioralLocked}
generalType="switch"
label={texts.events.sendReviewSmsLabel}
name="sendReviewRequestSms"
/>
</div>
</details>
</form>
</FormProvider>
)
}

View File

@ -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<EventWizardFormData>) => 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 (
<>
<div className="consumer-surface flex flex-col gap-4 p-4">
<CreateEventPreviewCard
categoryName={categoryName}
cityName={cityName}
data={data}
endsAt={endsAtPreview}
startsAt={startsAtPreview}
/>
</div>
<div className="consumer-surface flex flex-col gap-4 p-4 mt-2">
<FaqEditor
items={data.faqs}
onChange={(faqs) => {
onChange({ faqs })
}}
/>
</div>
</>
)
}

View File

@ -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<string[]>([])
const [isLinking, setIsLinking] = useState(false)
useEffect(() => {
if (!isOpen) return
setSearch('')
setSelectedIds([])
}, [isOpen])
const filteredAttendees = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return attendees
return attendees.filter((attendee) => {
const name = formatPersonName(attendee.firstName, attendee.lastName, '').toLowerCase()
return name.includes(query)
})
}, [attendees, search])
const allFilteredSelected = filteredAttendees.length > 0 && filteredAttendees.every((attendee) => selectedIds.includes(attendee.userId))
const toggleAttendee = (userId: string, checked: boolean) => {
setSelectedIds((prev) => (checked ? [...prev, userId] : prev.filter((id) => id !== userId)))
}
const toggleSelectAll = (checked: boolean) => {
const filteredIds = filteredAttendees.map((attendee) => attendee.userId)
if (checked) {
setSelectedIds((prev) => Array.from(new Set([...prev, ...filteredIds])))
return
}
setSelectedIds((prev) => prev.filter((id) => !filteredIds.includes(id)))
}
const handleSendInvitations = async () => {
if (selectedIds.length === 0) {
onOpenChange(false)
onFinish()
return
}
setIsLinking(true)
try {
await setPreviousAttendeeGuests(eventId, selectedIds)
addToast({ title: texts.events.guestsSaved, color: 'success' })
onOpenChange(false)
onFinish()
} catch {
addToast({ title: texts.events.guestsSaveFailed, color: 'danger' })
} finally {
setIsLinking(false)
}
}
return (
<ConsumerModal
bodyClassName="max-h-[min(60vh,28rem)]"
cancelLabel={texts.common.cancel}
isLoading={isLinking}
isOpen={isOpen}
size="md"
submitDisabled={selectedIds.length === 0}
submitLabel={texts.events.sendInvitation}
title={texts.events.invitePreviousGuestsTitle}
onClose={onFinish}
onOpenChange={onOpenChange}
onSubmit={() => void handleSendInvitations()}
>
<div className="flex flex-col gap-4">
<p className="text-sm leading-6 text-secondary-30">{texts.events.invitePreviousGuestsDescription}</p>
<ConsumerInput
isClearable
ariaLabel={texts.events.guestSearchAria}
generalType="input"
iconStart={<SearchAltIcon className="size-5 text-secondary-30" />}
name="guestSearch"
placeholder={texts.events.guestSearchPlaceholder}
value={search}
onClear={() => {
setSearch('')
}}
onValueChange={(next) => {
setSearch(coerceToString(next))
}}
/>
<ConsumerInput
generalType="checkbox"
label={texts.events.selectAllGuests}
name="selectAllGuests"
value={allFilteredSelected}
onValueChange={(checked) => {
toggleSelectAll(Boolean(checked))
}}
/>
<div className="flex flex-col gap-2">
{filteredAttendees.length === 0 ? (
<p className="rounded-consumer-control bg-consumer-surface-muted p-4 text-sm text-secondary-30">{texts.common.emptyChoice}</p>
) : (
filteredAttendees.map((attendee) => (
<div
key={attendee.userId}
className="rounded-consumer-control border border-consumer-border bg-consumer-surface p-3"
>
<ConsumerInput
generalType="checkbox"
label={formatPersonName(attendee.firstName, attendee.lastName, texts.events.guest)}
name={`guest-${attendee.userId}`}
value={selectedIds.includes(attendee.userId)}
onValueChange={(checked) => {
toggleAttendee(attendee.userId, Boolean(checked))
}}
/>
</div>
))
)}
</div>
</div>
</ConsumerModal>
)
}
export async function loadPreviousAttendeesForStep5(): Promise<PreviousAttendee[]> {
try {
return await fetchPreviousAttendees()
} catch {
return []
}
}

View File

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

View File

@ -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<EventReview | null>(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<string, EventReview>()
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 (
<section className="rounded-[22px] bg-consumer-surface p-[10px] shadow-sm">
<div className="flex items-start justify-between gap-3">
<h3 className="text-sm font-medium text-secondary-30">{texts.reviews.peopleSayTitle}</h3>
{reviewsCount > 0 && avgRating != null ? (
<div className="flex shrink-0 items-center gap-1 text-sm font-medium text-secondary-30">
<HeatIconFill className="size-3.5 text-primary" />
<span>
{format(texts.reviews.avgFromCount, {
rating: avgRating.toLocaleString('fa-IR', { maximumFractionDigits: 1 }),
count: reviewsCount.toLocaleString('fa-IR'),
})}
</span>
</div>
) : null}
</div>
{showReviewWindowExpired ? (
<p className="mt-2 text-xs font-medium text-fourth-700">{texts.errors.codes.REVIEW_WINDOW_EXPIRED}</p>
) : null}
{isLoading ? <p className="mt-4 text-sm font-medium text-secondary-30">{texts.reviews.loading}</p> : null}
{!isLoading && reviews.length === 0 ? (
<div className="mt-4 space-y-1">
<p className="text-sm font-medium text-secondary-30">{texts.reviews.eventEmptyTitle}</p>
<p className="text-sm font-medium text-secondary-30">{texts.reviews.eventEmptyHint}</p>
</div>
) : null}
{!isLoading && reviews.length > 0 ? (
<ul className="mt-4">
{reviews.map((review, index) => (
<ReviewCard
key={review.id}
authorAvatarUrl={review.user?.avatarUrl}
authorName={authorDisplayName(review)}
authorUserId={review.userId}
body={review.body}
className={index === 0 ? 'pb-4' : 'border-t border-secondary-45 py-4'}
createdAt={review.createdAt}
footer={
canHostReply ? (
<Button
className="mt-3 min-h-11"
size="sm"
variant="flat"
onClick={() => {
openReply(review)
}}
>
{review.hostReplyBody ? texts.reviews.editReply : texts.common.reply}
</Button>
) : null
}
hostReplyBody={review.hostReplyBody}
rating={review.rating}
tone="consumer"
/>
))}
</ul>
) : null}
{!isLoading && reviewsQuery.hasNextPage ? (
<Button
className="mt-3 w-full min-h-11"
isLoading={isLoadingMore}
variant="flat"
onClick={() => void reviewsQuery.fetchNextPage()}
>
{texts.reviews.loadMoreAlt}
</Button>
) : null}
{canReview ? (
<div className="mt-4 space-y-3">
<div className="rounded-[16px] bg-tertiary-900/5 p-3">
<p className="mb-2 text-sm font-medium text-tertiary-900">{texts.reviews.shareExperienceTitle}</p>
<ConsumerInput
generalType="textarea"
name="reviewDraftBody"
placeholder={texts.reviews.shareExperiencePlaceholder}
textAreaMinRows={3}
value={draftBody}
onValueChange={(next) => {
setDraftBody(coerceToString(next))
}}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Button
className="min-h-11 gap-2 bg-tertiary-900/10 px-4 text-sm font-medium text-tertiary-900 data-[hover=true]:bg-tertiary-900/15"
color="secondary-dark"
isLoading={isSaving}
variant="flat"
onClick={() => void handleSubmitReview()}
>
<AddDocumentIcon className="size-4 text-tertiary-900" />
{texts.reviews.submitReview}
</Button>
<StarRating
size="md"
value={draftRating}
onChange={setDraftRating}
/>
</div>
</div>
) : null}
{isHost && !replyWindowOpen && reviews.length > 0 ? (
<p className="mt-3 text-xs font-medium text-fourth-700">{texts.errors.codes.HOST_REPLY_WINDOW_EXPIRED}</p>
) : null}
<ConsumerModal
acceptBtnDisabled={!replyBody.trim() || isSavingReply}
acceptBtnText={texts.reviews.saveReply}
footerChildren={
replyTarget?.hostReplyBody ? (
<Button
color="danger"
disabled={isSavingReply}
size="sm"
variant="flat"
onClick={() => void clearReply()}
>
{texts.reviews.deleteReply}
</Button>
) : 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)
}}
>
<ConsumerInput
generalType="textarea"
label={texts.reviews.replyBodyLabel}
name="hostReplyBody"
textAreaMinRows={3}
value={replyBody}
onValueChange={(next) => {
setReplyBody(coerceToString(next))
}}
/>
</ConsumerModal>
</section>
)
}
export default EventReviewsSection

View File

@ -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<typeof ReviewsService>()
return {
...original,
LIST_ORGANIZER_REVIEWS_PAGE: (...args: unknown[]) => listPage(...args),
}
})
vi.mock('@/components/reviews/ReviewCard', () => ({
default: ({ body }: { body: string | null }) => <li>{body}</li>,
}))
vi.mock('@/components/reviews/StarRating', () => ({ default: () => <span /> }))
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(
<OrganizerReviewsSection
initialReviews={initialReviews}
organizerId="organizer-1"
reviewsCount={12}
/>
)
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()
})
})

View File

@ -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<OrganizerReview[]>(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 (
<section className="mt-10">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-xl font-extrabold">{texts.reviews.guestReviewsTitle}</h2>
{reviewsCount && reviewsCount > 0 && avgRating != null ? (
<div className="mt-1 flex items-center gap-2 text-sm font-medium text-secondary-30">
<HeatIconFill className="size-3.5 text-primary" />
<span>{avgRating.toLocaleString('fa-IR', { maximumFractionDigits: 1 })}</span>
<span className="text-xs text-tertiary-400">
{format(texts.events.reviewCountParen, { count: reviewsCount.toLocaleString('fa-IR') })}
</span>
</div>
) : (
<p className="mt-1 text-xs text-tertiary-400">{texts.reviews.organizerEmptyHint}</p>
)}
</div>
</div>
{reviews.length === 0 ? (
<ConsumerState
title={texts.reviews.organizerEmptyTitle}
variant="empty"
/>
) : (
<>
<ul className="space-y-3">
{reviews.map((review) => (
<ReviewCard
key={review.id}
authorAvatarUrl={review.user?.avatarUrl}
authorName={[review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ')}
authorUserId={review.userId}
body={review.body}
createdAt={review.createdAt}
headerExtra={
<Link
className="mt-2 inline-block text-xs font-bold text-primary hover:underline"
href={SEO_ROUTES.EVENT_LANDING(review.eventSlug)}
>
{review.eventTitle}
</Link>
}
hostReplyBody={review.hostReplyBody}
rating={review.rating}
tone="organizer"
/>
))}
</ul>
{hasMore ? (
<div className="mt-4 flex justify-center">
<Button
isLoading={isLoadingMore}
variant="flat"
onClick={() => void loadMore()}
>
{texts.reviews.loadMore}
</Button>
</div>
) : null}
</>
)}
</section>
)
}
export default OrganizerReviewsSection

View File

@ -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 (
<ConsumerModal
acceptBtnDisabled={rating < 1 || isSaving}
acceptBtnText={texts.reviews.submitReview}
isLoading={isSaving}
isOpen={isOpen}
rejectBtnText={texts.common.cancel}
size="md"
title={texts.reviews.createTitle}
onAccept={() => void handleSave()}
onOpenChange={onOpenChange}
onReject={() => {
onOpenChange(false)
}}
>
<div className="space-y-4">
<div>
<p className="mb-2 text-sm text-secondary-20">{texts.reviews.yourRating}</p>
<StarRating
value={rating}
onChange={setRating}
/>
</div>
<ConsumerInput
generalType="textarea"
label={texts.reviews.bodyLabel}
name="reviewBody"
placeholder={texts.reviews.bodyPlaceholder}
textAreaMinRows={3}
value={body}
onValueChange={(next) => {
setBody(coerceToString(next))
}}
/>
<p className="text-xs text-default-400">{texts.reviews.ratingOnlyHint}</p>
</div>
</ConsumerModal>
)
}
export default ReviewModal

View File

@ -159,7 +159,7 @@ export const siteConfig = (): { userSidebar: SidebarRoute[] } => ({
export const metadata: Metadata = {
title: {
default: 'قبیله — پنل مدیریت',
template: `%s - قبیله`,
template: `%s | قبیله Backoffice`,
},
description: 'پنل مدیریت پلتفرم قبیله',
icons: {

View File

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

View File

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

View File

@ -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": "قبیله · روایت مدرن غریزه قدیمی دور هم جمع شدن."
}

View File

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

View File

@ -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": "ساخت رویداد"
}
}

View File

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

View File

@ -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": "۸. سوابق پذیرش الکترونیکی قرارداد در حدود قوانین قابل استناد است."
}
]
}
]
}

View File

@ -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": "پیام شما ثبت شد. به‌زودی پاسخ می‌دهیم."
}
}
}

View File

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

View File

@ -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<T>` (`{ 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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}
```
(`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.

View File

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

View File

@ -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 (~360430px) 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.

View File

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

View File

@ -1,124 +0,0 @@
<!DOCTYPE html>
<!-- saved from url=(0058)file:///C:/Users/user/Downloads/ghabilee-about-mobile.html -->
<html lang="fa" dir="rtl"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#fffdf9">
<title>درباره قبیله | جایی برای تجربه‌های حضوری و جمع‌های تازه</title>
<meta name="description" content="قبیله جایی برای پیدا کردن و تجربه کردن رویدادهای حضوری و ساختن جمع‌های تازه است؛ از کشف و رزرو یک تجربه تا میزبانی و جمع کردن آدم‌ها دور چیزی که دوست دارید.">
<style>
:root{--bg:#fffdf9;--paper:#fff;--ink:#171717;--muted:#6f6f73;--line:#ece8df;--soft:#f6f3ed;--orange:#ff8a1f;--orange-deep:#e8710c;--orange-soft:#fff0df;--violet:#9b24e8;--violet-soft:#f4e9fb;--night:#1f1c24;--shadow:0 14px 38px rgba(29,22,12,.075);--pad:20px}
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;color:var(--ink);background:radial-gradient(circle at 88% -2%,rgba(155,36,232,.07),transparent 18rem),radial-gradient(circle at 10% 17%,rgba(255,138,31,.08),transparent 17rem),var(--bg);font-family:Tahoma,Arial,"Segoe UI",sans-serif;line-height:1.9;-webkit-font-smoothing:antialiased;padding-bottom:88px} a{text-decoration:none;color:inherit} button,a{font:inherit} svg{display:block}
.page{width:min(100%,430px);margin:0 auto;overflow:hidden}.header{position:sticky;top:0;z-index:30;min-height:58px;padding:8px 18px;display:flex;align-items:center;justify-content:space-between;background:rgba(255,253,249,.9);backdrop-filter:blur(14px);border-bottom:1px solid rgba(236,232,223,.75)}
.brand{display:flex;align-items:center;gap:8px;color:var(--violet);font-weight:900}.brand-mark{width:34px;height:34px;border:1.5px solid var(--violet);border-radius:11px;display:grid;place-items:center;background:#fff;font-size:18px;font-weight:900}.menu{width:44px;height:44px;border:0;border-radius:14px;background:transparent;display:grid;place-items:center;cursor:pointer}.menu:active{background:var(--soft)}
.hero{padding:32px var(--pad) 0;position:relative}.eyebrow{display:inline-flex;align-items:center;gap:7px;color:var(--orange-deep);font-size:13px;font-weight:900;margin-bottom:12px}.eyebrow-dot{width:6px;height:6px;border-radius:50%;background:var(--orange);box-shadow:0 0 0 5px var(--orange-soft)}
h1,h2,h3,p{margin:0} h1{font-size:34px;line-height:1.47;letter-spacing:-1.5px;margin-bottom:12px} h1 strong{color:var(--violet)} h2{font-size:27px;line-height:1.55;letter-spacing:-.9px;margin-bottom:12px} h2 strong{color:var(--orange-deep)} h3{font-size:18px;line-height:1.7;letter-spacing:-.25px}.lead{color:#434348;font-size:16px;line-height:2}.muted{color:var(--muted)}
.hero-visual{position:relative;min-height:310px;margin-top:24px;overflow:hidden;border-radius:30px;background:radial-gradient(circle at 50% 52%,rgba(255,138,31,.36),rgba(255,138,31,0) 25%),linear-gradient(160deg,#241d2a,#1b1b20 65%,#2c2119);box-shadow:0 20px 45px rgba(28,22,16,.16)}.city-grid{position:absolute;inset:0;opacity:.28;background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px);background-size:26px 26px;transform:perspective(250px) rotateX(58deg) scale(1.5) translateY(80px);transform-origin:center bottom}.fire{position:absolute;left:50%;top:50%;width:76px;height:76px;transform:translate(-50%,-42%);border-radius:50%;background:radial-gradient(circle,#ffd799 0 16%,#ff9f42 17% 34%,#ff6f1c 35% 49%,rgba(255,111,28,.16) 50% 68%,transparent 69%);box-shadow:0 0 40px rgba(255,138,31,.58),0 0 90px rgba(255,138,31,.24)}.ring{position:absolute;left:50%;top:50%;border:1px solid rgba(255,255,255,.12);border-radius:50%;transform:translate(-50%,-50%)}.ring.one{width:155px;height:155px}.ring.two{width:235px;height:235px}.ring.three{width:320px;height:320px}.person{position:absolute;width:18px;height:18px;border-radius:50%;background:#f2eee8;box-shadow:0 6px 0 -1px rgba(242,238,232,.65)}.p1{top:52px;right:112px}.p2{top:93px;left:72px}.p3{bottom:62px;right:66px}.p4{bottom:48px;left:112px}.p5{top:63px;left:164px}.p6{bottom:103px;right:152px}.hero-caption{position:absolute;left:16px;right:16px;bottom:14px;padding:12px 14px;border:1px solid rgba(255,255,255,.12);border-radius:16px;background:rgba(255,255,255,.08);color:#f3edf6;font-size:12px;text-align:center;backdrop-filter:blur(8px)}
.section{padding:68px var(--pad) 0;position:relative}.quote{margin-top:24px;padding:20px;border-radius:24px;background:linear-gradient(145deg,var(--orange-soft),#fff8ef);border:1px solid #f7dfc1;font-size:18px;font-weight:900;line-height:1.8;color:#6d421a;box-shadow:var(--shadow)}
.story-line{margin-top:26px;position:relative;padding-right:28px}.story-line:before{content:"";position:absolute;right:8px;top:6px;bottom:7px;width:2px;background:linear-gradient(var(--orange),#ddd6cb)}.story-item{position:relative;padding-bottom:30px}.story-item:last-child{padding-bottom:0}.story-item:before{content:"";position:absolute;right:-26px;top:5px;width:14px;height:14px;border-radius:50%;border:4px solid var(--orange);background:#fff;box-shadow:0 0 0 5px var(--orange-soft)}.story-label{color:var(--orange-deep);font-size:11px;font-weight:900;margin-bottom:4px}.story-item p{color:var(--muted);font-size:13px;margin-top:5px}
.what-card{margin-top:22px;padding:20px;border:1px solid var(--line);border-radius:24px;background:#fff;box-shadow:var(--shadow)}.what-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px}.what-item{min-height:104px;padding:14px;border-radius:17px;background:var(--soft)}.what-item:nth-child(2),.what-item:nth-child(3){background:var(--violet-soft)}.what-item b{display:block;font-size:14px}.what-item span{display:block;margin-top:5px;color:var(--muted);font-size:11px}
.reasons{display:grid;gap:10px;margin-top:22px}.reason{padding:16px 16px 16px 14px;border:1px solid var(--line);border-radius:18px;background:#fff;display:grid;grid-template-columns:42px 1fr;gap:12px;align-items:start}.reason-icon{width:42px;height:42px;border-radius:14px;display:grid;place-items:center;background:var(--orange-soft);color:var(--orange-deep)}.reason:nth-child(even) .reason-icon{background:var(--violet-soft);color:var(--violet)}.reason p{color:var(--muted);font-size:12px;margin-top:4px}
.paths{display:grid;gap:12px;margin-top:22px}.path-card{padding:20px;border-radius:24px;border:1px solid var(--line);background:#fff;box-shadow:var(--shadow);overflow:hidden;position:relative}.path-card:after{content:"";position:absolute;width:130px;height:130px;border-radius:50%;left:-68px;bottom:-68px;background:rgba(255,138,31,.08)}.path-card.host:after{background:rgba(155,36,232,.09)}.path-kicker{font-size:11px;color:var(--orange-deep);font-weight:900;margin-bottom:6px}.host .path-kicker{color:var(--violet)}.path-card p{font-size:13px;color:var(--muted);margin-top:8px}.path-scenario{margin-top:15px;padding:13px;border-radius:14px;background:var(--soft);font-size:12px;position:relative;z-index:2}
.vision{margin-top:22px;padding:28px 20px;border-radius:28px;color:#f5f2f7;background:radial-gradient(circle at 20% 0%,rgba(255,138,31,.25),transparent 13rem),radial-gradient(circle at 90% 100%,rgba(155,36,232,.28),transparent 13rem),linear-gradient(155deg,#201c25,#18181d);box-shadow:0 20px 45px rgba(24,20,28,.18)}.vision p{color:#d1ccd5;font-size:14px;margin-top:10px}.vision-list{margin-top:20px;display:grid;gap:9px}.vision-item{padding:12px 13px;border-radius:14px;border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.06);font-size:12px}
.values{display:grid;gap:10px;margin-top:22px}.value{padding:17px;border-radius:19px;border:1px solid var(--line);background:#fff}.value-head{display:flex;align-items:center;gap:10px}.value-no{width:32px;height:32px;border-radius:11px;display:grid;place-items:center;background:var(--orange-soft);color:var(--orange-deep);font-size:11px;font-weight:900}.value:nth-child(even) .value-no{background:var(--violet-soft);color:var(--violet)}.value p{color:var(--muted);font-size:12px;margin-top:8px}
.final{margin-top:70px;padding:42px 20px 36px;color:#fff;background:radial-gradient(circle at 16% 12%,rgba(255,138,31,.26),transparent 13rem),linear-gradient(160deg,#1a1a20,#2a202f 62%,#2d2118);position:relative;overflow:hidden}.final:before,.final:after{content:"";position:absolute;border:1px solid rgba(255,255,255,.08);border-radius:50%}.final:before{width:260px;height:260px;left:-130px;top:-100px}.final:after{width:200px;height:200px;right:-120px;bottom:-90px}.final h2,.final p,.final .actions{position:relative;z-index:2}.final p{color:#d5d1d8;font-size:14px;margin-top:10px}.actions{display:grid;gap:10px;margin-top:22px}.btn{min-height:56px;border-radius:17px;display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:900;padding:0 16px}.btn.primary{background:linear-gradient(135deg,var(--orange),#ff9c42);color:#fff;box-shadow:0 12px 28px rgba(255,138,31,.26)}.btn.secondary{border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.08);color:#fff}.footer{padding:24px 20px 10px;text-align:center;color:#9a9a9d;font-size:11px}
.sticky{position:fixed;left:50%;bottom:max(10px,env(safe-area-inset-bottom));transform:translateX(-50%);width:min(calc(100% - 20px),410px);min-height:66px;padding:8px;border:1px solid rgba(232,228,220,.95);border-radius:21px;background:rgba(255,255,255,.92);box-shadow:0 16px 50px rgba(22,18,12,.18);backdrop-filter:blur(16px);display:flex;gap:8px;z-index:50;transition:.22s ease}.sticky.hidden{opacity:0;pointer-events:none;transform:translateX(-50%) translateY(20px)}.sticky a{flex:1;min-height:48px;border-radius:15px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:900}.sticky .discover{background:var(--orange);color:#fff}.sticky .host{background:var(--violet-soft);color:var(--violet)}
@media (min-width:700px){body{background:#f1eee8;padding:30px 0 110px}.page{background:var(--bg);border-radius:36px;box-shadow:0 30px 80px rgba(40,30,20,.12)}.header{border-radius:36px 36px 0 0}} @media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}.sticky{transition:none}}
</style>
</head>
<body>
<main class="page">
<header class="header">
<a class="brand" href="file:///C:/Users/user/Downloads/ghabilee-about-mobile.html#" aria-label="قبیله"><span class="brand-mark">ق</span><span>قبیله</span></a>
<button class="menu" aria-label="منو"><svg width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M5 7h14M5 12h14M5 17h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path></svg></button>
</header>
<section class="hero" id="hero">
<div class="eyebrow"><span class="eyebrow-dot"></span>درباره قبیله</div>
<h1>ما هنوز هم دور چیزهایی که <strong>دوست داریم</strong> جمع می‌شیم.</h1>
<p class="lead">فقط شکلش عوض شده.<br><br> یه روز دور آتیش، امروز دور یه میز، یه بازی، یه کارگاه، یه اجرا یا هر چیزی که ارزش بیرون اومدن و با هم تجربه کردن داشته باشه.
<br>
قبیله برای همین جمع ها ساخته شده</p>
</section>
<section class="section">
<div class="eyebrow"><span class="eyebrow-dot"></span>غریزه قدیمی، زندگی امروز</div>
<h2>هزاران ساله<br><strong>همین کارو می‌کنیم.</strong></h2>
<p class="lead">آدم‌ها همیشه دنبال آدم‌های خودشون بودن؛ آدم‌هایی که یه چیز مشترک دارن. یه علاقه، یه کنجکاوی، یه مهارت، یه سلیقه یا حتی فقط حال‌وهوای یه شب.</p>
<div class="story-line">
<article class="story-item"><div class="story-label">اون موقع</div><h3>دور آتیش</h3><p>یه نقطه برای جمع شدن، حرف زدن، تجربه کردن و ساختن چیزی مشترک.</p></article>
<article class="story-item"><div class="story-label">امروز</div><h3>دور یه میز، یه بازی، یه اجرا</h3><p>بهونه‌ها عوض شدن، ولی نیاز به جمع هنوز همونه.</p></article>
<article class="story-item"><div class="story-label">مشکل امروز</div><h3>پیدا کردن این جمع‌ها همیشه راحت نیست</h3><p>خیلی از تجربه‌ها بین چند صفحه، پیام، لینک پرداخت و گروه پراکنده می‌شن. خیلی‌ها هم اصلاً نمی‌فهمن دوروبرشون چه خبره.</p></article>
</div>
<div class="quote">قبیله می‌خواد این فاصله رو کمتر کنه.</div>
</section>
<section class="section">
<div class="eyebrow"><span class="eyebrow-dot"></span>قبیله دقیقاً چیه؟</div>
<h2>جایی برای پیدا کردن،<br><strong>رفتن و جمع شدن.</strong></h2>
<p class="lead">قبیله جاییه برای پیدا کردن و تجربه کردن رویدادهای حضوری. می‌تونی ببینی این دور و برا چه خبره، یه تجربه تازه پیدا کنی، جات رو توی یه جمع نگه داری و اگه چیزی برای ساختن داری، خودت میزبان بشی.</p>
<div class="what-card"><div class="what-grid"><div class="what-item"><b>کشف کن</b><span>ببین این دور و برا چه خبره.</span></div><div class="what-item"><b>جاتو نگه دار</b><span>رزرو و پرداخت یک‌جا.</span></div><div class="what-item"><b>آدم‌ها رو پیدا کن</b><span>میزبان‌ها، نظرها و تجربه‌های مشترک.</span></div><div class="what-item"><b>میزبان شو</b><span>یه تجربه بساز و جمعش رو راه بنداز.</span></div></div></div>
</section>
<section class="section">
<div class="eyebrow"><span class="eyebrow-dot"></span>بهونه‌های خوب</div>
<h2>هر جمعی<br><strong>یه بهونه می‌خواد.</strong></h2>
<div class="reasons">
<article class="reason"><div class="reason-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M8 5h8M7 9h10M6 13h12M8 17h8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path></svg></div><div><h3>یه کارگاه</h3><p>یه چیزی یاد بگیری و آدم‌هایی رو ببینی که همون کنجکاوی رو دارن.</p></div></article>
<article class="reason"><div class="reason-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M7 8h10v8H7zM9 10h.01M15 14h.01M12 12h.01" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path></svg></div><div><h3>یه بازی</h3><p>یه شب معمولی رو به یه خاطره مشترک تبدیل کنی.</p></div></article>
<article class="reason"><div class="reason-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 4v16M8 8c0-2 1.8-3 4-3s4 1 4 3-1.8 3-4 3-4 1-4 3 1.8 3 4 3 4-1 4-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"></path></svg></div><div><h3>یه اجرا</h3><p>چیزی رو ببینی که شاید توی مسیر معمول روزمره بهش نمی‌رسیدی.</p></div></article>
<article class="reason"><div class="reason-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M7 17c1.5-4 4.5-7 10-10M9 7h8v8" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path></svg></div><div><h3>یه تجربه تازه</h3><p>چیزی که هنوز امتحانش نکردی و شاید بخوای دوباره برگردی سراغش.</p></div></article>
</div>
<div class="quote" style="margin-top:22px">برای ما خود «رویداد» آخر داستان نیست؛ فقط بهونه‌ایه که چند نفر برای چند ساعت از مسیرهای جدا بیان و یه تجربه رو با هم زندگی کنن.</div>
<div>بعضی از این جمع ها همون شب تموم میشن. بعضی ها تبدیل به یه خاطره میشن و بعضی ها باعث میشن آدم هایی رو پیدا کنی که دلت بخواد دوباره ببینیشون.</div>
</section>
<section class="section">
<div class="eyebrow"><span class="eyebrow-dot"></span>دو مسیر، یک جمع</div>
<h2>یه وقت می‌خوای بری.<br><strong>یه وقت می‌خوای بسازی.</strong></h2>
<div class="paths">
<article class="path-card"><div class="path-kicker">برای کسی که می‌خواد بره</div><h3>همه تجربه‌های خوب از مدت‌ها قبل برنامه‌ریزی نمی‌شن.</h3><p>گاهی چهارشنبه‌ست و فقط می‌خوای ببینی آخر هفته چه خبره. گاهی مدت‌هاست دلت می‌خواد چیزی رو امتحان کنی ولی کسی رو پیدا نکردی که باهات بیاد.</p><div class="path-scenario">گاهی فقط یه رویداد می‌بینی و با خودت می‌گی: <b>«این بار برم.»</b></div></article>
<article class="path-card host"><div class="path-kicker">برای کسی که می‌خواد یه جمع بسازه</div><h3>پشت هر تجربه خوب، یه نفر تصمیم گرفته چیزی رو راه بندازه.</h3><p>قبیله کمک می‌کنه ثبت‌نام‌ها بین پیام‌ها گم نشن، پرداخت‌ها یک‌جا بمونن و ظرفیت، مهمان‌ها و فهرست انتظار قابل مدیریت باشن.</p><div class="path-scenario">ما به این آدم می‌گیم: <b>میزبان.</b></div></article>
</div>
</section>
<section class="section">
<div class="eyebrow"><span class="eyebrow-dot"></span>چیزی که می‌خوایم بسازیم</div>
<div class="vision"><h3 style="font-size:21px">چیزی که کم داریم، جاهایی برای پیدا کردن آدم‌ها و تجربه‌هاییه که شاید توی مسیر معمول زندگی بهشون نمی‌رسیدیم.</h3><div class="vision-list"><div class="vision-item">یه تصمیم ساده تبدیل به یه شب متفاوت بشه.</div><div class="vision-item">یه علاقه شخصی تبدیل به یه تجربه مشترک بشه.</div><div class="vision-item">یه میزبان آدم‌های درست رو دور چیزی که ساخته جمع کنه.</div><div class="vision-item">یه تجربه خوب، ردپایی برای جمع بعدی بذاره.</div></div></div>
</section>
<section class="section">
<div class="eyebrow"><span class="eyebrow-dot"></span>چیزهایی که برای ما مهمه</div>
<h2>چند اصل ساده<br><strong>برای یه جمع بهتر.</strong></h2>
<div class="values">
<article class="value"><div class="value-head"><div class="value-no">۴</div><h3>تجربه در دنیای واقعی</h3></div><p>قبیله روی صفحه شروع می‌شه، اما چیزی که برای ما مهمه بیرون از صفحه اتفاق می‌افته.</p></article><article class="value"><div class="value-head"><div class="value-no">۱</div><h3>آدم‌ها قبل از عددها</h3></div><p>رشد وقتی ارزش داره که تجربه آدم‌هایی که وارد این جمع می‌شن خراب نشه.</p></article>
<article class="value"><div class="value-head"><div class="value-no">۵</div><h3>جمع، نه فقط برنامه</h3></div><p>گاهی چیزی که از یه رویداد با خودت می‌بری فقط چیزی نیست که یاد گرفتی یا دیدی؛ آدم‌هاییه که اونجا پیدا کردی.</p></article><article class="value"><div class="value-head"><div class="value-no">۲</div><h3>شفاف بودن قبل از خرید</h3></div><p>باید بدونی کجا می‌ری، برای چی می‌ری، چه چیزی منتظرته و چقدر پرداخت می‌کنی.</p></article>
<article class="value"><div class="value-head"><div class="value-no">۳</div><h3>اعتماد دوطرفه</h3></div><p>هم مهمان باید بدونه پشت یه رویداد چه کسیه، هم میزبان باید بدونه چه کسانی ثبت‌نام کردن.</p></article>
</div>
</section>
<section class="final" id="final">
<h2>شاید جمع بعدی<br><strong style="color:#ffad67">همین دور و برا باشه.</strong></h2>
<div class="actions"><a class="btn primary" href="file:///C:/category">ببین این دور و برا چه خبره</a></div>
</section>
<footer class="footer">قبیله · روایت مدرن غریزه قدیمی دور هم جمع شدن.</footer>
</main>
<div class="sticky" id="sticky"><a class="discover" href="file:///C:/category">چه خبره؟</a><a class="host" href="file:///C:/become-a-host">یه جمع راه بنداز</a></div>
<script>
const sticky=document.getElementById('sticky'); const hero=document.getElementById('hero'); const final=document.getElementById('final'); const state={hero:true,final:false};
function sync(){sticky.classList.toggle('hidden',state.hero||state.final)}
const obs=new IntersectionObserver((entries)=>{entries.forEach(entry=>{if(entry.target===hero)state.hero=entry.isIntersecting;if(entry.target===final)state.final=entry.isIntersecting});sync()},{threshold:.12});
obs.observe(hero); obs.observe(final);
</script>
</body></html>

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,469 +0,0 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>هنر و فرهنگ | قبیله</title>
<style>
:root{
--bg:#f4f1ea;
--surface:#fffdf8;
--ink:#171714;
--muted:#6f6b63;
--line:#d9d3c7;
--accent:#da5a32;
--accent-dark:#ad3e1e;
--dark:#1e211d;
--soft:#e9e2d5;
--radius:24px;
--shadow:0 12px 34px rgba(31,31,27,.08);
--max:1200px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;
background:var(--bg);
color:var(--ink);
font-family:Tahoma, Arial, sans-serif;
line-height:1.9;
}
a{color:inherit;text-decoration:none}
button,input,select{font:inherit}
.container{width:min(calc(100% - 40px),var(--max));margin:auto}
.topbar{
position:sticky;top:0;z-index:50;
background:rgba(244,241,234,.92);
backdrop-filter:blur(16px);
border-bottom:1px solid rgba(217,211,199,.8);
}
.nav{
min-height:74px;display:flex;align-items:center;justify-content:space-between;gap:24px
}
.brand{font-size:26px;font-weight:900;letter-spacing:-1px}
.brand-mark{
display:inline-flex;width:14px;height:14px;border-radius:50%;background:var(--accent);
margin-left:8px;box-shadow:18px 0 0 -3px var(--ink)
}
.nav-links{display:flex;align-items:center;gap:28px;font-size:14px}
.nav-actions{display:flex;align-items:center;gap:10px}
.btn{
border:0;border-radius:999px;padding:11px 18px;cursor:pointer;
transition:.2s ease;display:inline-flex;align-items:center;justify-content:center;gap:7px
}
.btn-primary{background:var(--ink);color:white}
.btn-primary:hover{transform:translateY(-1px);background:#000}
.btn-outline{border:1px solid var(--line);background:transparent}
.btn-outline:hover{background:var(--surface)}
.btn-accent{background:var(--accent);color:#fff}
.btn-accent:hover{background:var(--accent-dark)}
.breadcrumb{font-size:12px;color:var(--muted);padding-top:26px}
.hero{
display:grid;grid-template-columns:1.05fr .95fr;gap:54px;align-items:center;
padding:42px 0 54px
}
.eyebrow{font-size:13px;color:var(--accent-dark);font-weight:700;margin-bottom:8px}
h1{font-size:clamp(42px,6vw,78px);line-height:1.08;margin:0 0 18px;letter-spacing:-3px}
.hero h2{font-size:24px;line-height:1.5;margin:0 0 14px;font-weight:800}
.hero p{font-size:17px;color:#45423d;margin:0 0 26px;max-width:680px}
.hero-actions{display:flex;gap:10px;flex-wrap:wrap}
.hero-art{
min-height:420px;border-radius:36px;position:relative;overflow:hidden;
background:
radial-gradient(circle at 70% 32%, rgba(218,90,50,.95) 0 8%, transparent 8.5%),
radial-gradient(circle at 37% 65%, rgba(244,241,234,.9) 0 11%, transparent 11.5%),
linear-gradient(145deg,#292f29 0 38%,#c8b08d 38% 62%,#7a3b27 62%);
box-shadow:var(--shadow);
}
.hero-art:before,.hero-art:after{
content:"";position:absolute;border:1px solid rgba(255,255,255,.36);border-radius:50%
}
.hero-art:before{width:260px;height:260px;left:40px;top:48px}
.hero-art:after{width:390px;height:390px;right:-120px;bottom:-150px}
.hero-note{
position:absolute;left:24px;bottom:22px;background:rgba(255,253,248,.92);
padding:12px 14px;border-radius:16px;max-width:230px;font-size:13px;
box-shadow:0 8px 30px rgba(0,0,0,.12)
}
.events{padding:52px 0 80px}
.section-head{
display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:22px
}
.section-head h2{font-size:34px;line-height:1.35;margin:0 0 5px;letter-spacing:-1px}
.section-head p{margin:0;color:var(--muted)}
.count{font-size:13px;color:var(--muted)}
.filters{
display:grid;grid-template-columns:1fr auto;gap:14px;align-items:center;
margin-bottom:26px
}
.search{
display:flex;align-items:center;gap:10px;background:var(--surface);
border:1px solid var(--line);border-radius:18px;padding:0 16px;height:54px
}
.search input{width:100%;border:0;outline:0;background:transparent;color:var(--ink)}
.filter-group{display:flex;gap:8px;flex-wrap:wrap}
.chip{
border:1px solid var(--line);background:var(--surface);border-radius:999px;
padding:10px 14px;cursor:pointer;font-size:13px
}
.chip.active{background:var(--ink);color:#fff;border-color:var(--ink)}
.sort-row{
display:flex;justify-content:space-between;align-items:center;margin:12px 0 20px;font-size:13px
}
.sort-row select{
border:1px solid var(--line);background:var(--surface);border-radius:12px;padding:8px 12px
}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
.card{
display:block;background:var(--surface);border-radius:22px;overflow:hidden;
border:1px solid rgba(217,211,199,.9);transition:.25s ease
}
.card:hover{transform:translateY(-5px);box-shadow:var(--shadow)}
.thumb{aspect-ratio:16/10;position:relative;overflow:hidden}
.thumb:after{
content:"";position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.25),transparent 50%)
}
.t1{background:linear-gradient(135deg,#7a3227,#e4a86f)}
.t2{background:linear-gradient(135deg,#252933,#9f6d64)}
.t3{background:linear-gradient(135deg,#172f2a,#81a77e)}
.t4{background:linear-gradient(135deg,#6d604e,#d4c2a2)}
.t5{background:linear-gradient(135deg,#b95539,#f0d69f)}
.t6{background:linear-gradient(135deg,#213245,#829db3)}
.t7{background:linear-gradient(135deg,#3c2d30,#bb8e84)}
.t8{background:linear-gradient(135deg,#706338,#ddd39e)}
.t9{background:linear-gradient(135deg,#313a29,#a4b48b)}
.t10{background:linear-gradient(135deg,#462c5c,#b697c8)}
.t11{background:linear-gradient(135deg,#183943,#86b6b9)}
.t12{background:linear-gradient(135deg,#653f2b,#e3b48d)}
.t13{background:linear-gradient(135deg,#3d3d3d,#a7a7a7)}
.t14{background:linear-gradient(135deg,#78512f,#d1a767)}
.t15{background:linear-gradient(135deg,#4b2740,#d68eaf)}
.thumb-symbol{
position:absolute;inset:0;display:grid;place-items:center;font-size:54px;
color:rgba(255,255,255,.86);font-weight:900;z-index:1
}
.badge{
position:absolute;top:14px;right:14px;z-index:2;background:rgba(255,253,248,.92);
border-radius:999px;padding:6px 10px;font-size:11px
}
.save{
position:absolute;top:14px;left:14px;z-index:2;width:34px;height:34px;border-radius:50%;
border:0;background:rgba(255,253,248,.92);font-size:18px;cursor:pointer
}
.card-body{padding:18px}
.card-title{font-size:18px;line-height:1.55;font-weight:800;margin-bottom:5px}
.card-desc{font-size:13px;color:var(--muted);min-height:28px}
.meta{margin-top:14px;display:grid;gap:5px;font-size:12px;color:#49453f}
.card-foot{
display:flex;justify-content:space-between;align-items:center;
padding-top:14px;margin-top:14px;border-top:1px solid #ece6da
}
.price{font-weight:800;font-size:13px}
.host{font-size:11px;color:var(--muted)}
.pagination{
display:flex;justify-content:center;align-items:center;gap:7px;margin-top:38px
}
.page{
width:42px;height:42px;border-radius:12px;border:1px solid var(--line);
background:var(--surface);display:grid;place-items:center
}
.page.active{background:var(--ink);color:#fff}
.article-wrap{
background:var(--surface);border-top:1px solid var(--line);border-bottom:1px solid var(--line);
padding:84px 0
}
.article{
max-width:820px;margin:auto
}
.article-kicker{color:var(--accent-dark);font-size:13px;font-weight:800}
.article h2{font-size:40px;line-height:1.35;margin:7px 0 24px;letter-spacing:-1.5px}
.article h3{font-size:25px;line-height:1.5;margin:52px 0 14px}
.article p{font-size:16px;color:#3f3b36;margin:0 0 18px}
.article blockquote{
margin:42px 0;padding:28px;border-radius:22px;background:var(--soft);
font-size:23px;font-weight:800;line-height:1.7;border:0
}
.article ul{padding-right:22px;color:#3f3b36}
.article li{margin-bottom:8px}
.reading-nav{
border:1px solid var(--line);background:#faf7f0;border-radius:20px;padding:22px;margin:34px 0 52px
}
.reading-nav strong{display:block;margin-bottom:8px}
.reading-nav a{display:inline-block;margin:3px 0 3px 16px;font-size:13px;color:#4f4b44}
.host-cta{
width:min(calc(100% - 40px),var(--max));margin:80px auto;
display:grid;grid-template-columns:1fr .85fr;gap:44px;align-items:center;
background:var(--dark);color:#fff;border-radius:34px;padding:46px
}
.host-cta h2{font-size:38px;line-height:1.4;margin:0 0 14px}
.host-cta p{color:#d8d6d0;margin:0 0 22px}
.host-points{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:20px 0}
.host-point{border:1px solid #41463f;border-radius:14px;padding:11px 13px;font-size:13px}
.host-art{
min-height:320px;border-radius:24px;background:
radial-gradient(circle at 50% 50%,#ef784b 0 13%,transparent 13.5%),
repeating-radial-gradient(circle at 50% 50%,transparent 0 35px,rgba(255,255,255,.18) 36px 37px),
linear-gradient(135deg,#313a31,#1c1f1b);
}
.faq{width:min(calc(100% - 40px),900px);margin:0 auto 90px}
.faq h2{font-size:34px;margin-bottom:18px}
details{
border-top:1px solid var(--line);padding:18px 0
}
details:last-child{border-bottom:1px solid var(--line)}
summary{cursor:pointer;font-weight:800;list-style:none}
summary::-webkit-details-marker{display:none}
details p{color:var(--muted);font-size:14px;margin:12px 0 0}
footer{background:#171714;color:#ddd8cd;padding:48px 0}
.footer-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:30px}
footer h3{margin-top:0;color:#fff}
footer p, footer a{font-size:13px;color:#aaa69d}
.copyright{border-top:1px solid #31312d;margin-top:32px;padding-top:20px;font-size:11px;color:#77756e}
@media(max-width:900px){
.nav-links{display:none}
.hero{grid-template-columns:1fr;gap:30px;padding-top:28px}
.hero-art{min-height:300px}
.filters{grid-template-columns:1fr}
.grid{grid-template-columns:repeat(2,1fr)}
.host-cta{grid-template-columns:1fr}
}
@media(max-width:620px){
.container{width:min(calc(100% - 24px),var(--max))}
.nav{min-height:64px}
.nav-actions .btn-outline{display:none}
.breadcrumb{padding-top:18px}
.hero{padding:24px 0 38px}
h1{font-size:48px;letter-spacing:-2px}
.hero h2{font-size:21px}
.hero p{font-size:14px}
.hero-art{min-height:240px;border-radius:24px}
.events{padding:34px 0 56px}
.section-head{display:block}
.section-head h2{font-size:28px}
.count{margin-top:5px}
.filter-group{overflow-x:auto;flex-wrap:nowrap;padding-bottom:5px}
.chip{white-space:nowrap}
.sort-row{gap:10px}
.grid{grid-template-columns:1fr;gap:18px}
.article-wrap{padding:56px 0}
.article{width:min(calc(100% - 28px),820px)}
.article h2{font-size:31px}
.article h3{font-size:22px}
.article blockquote{font-size:19px;padding:22px}
.host-cta{width:calc(100% - 24px);margin:52px auto;padding:28px;border-radius:24px}
.host-cta h2{font-size:29px}
.host-points{grid-template-columns:1fr}
.faq{width:calc(100% - 28px);margin-bottom:60px}
.footer-grid{grid-template-columns:1fr}
}
</style>
</head>
<body>
<header class="topbar">
<div class="container nav">
<a class="brand" href="#"><span class="brand-mark"></span>قبیله</a>
<nav class="nav-links">
<a href="#events">کشف رویدادها</a>
<a href="#article">درباره این دسته</a>
<a href="#host">برگزاری رویداد</a>
</nav>
<div class="nav-actions">
<a class="btn btn-outline" href="#events">جست‌وجو</a>
<a class="btn btn-primary" href="#">ورود</a>
</div>
</div>
</header>
<main>
<div class="container breadcrumb">خانه ← رویدادها ← هنر و فرهنگ</div>
<section class="container hero">
<div>
<div class="eyebrow">رویدادهای هنر و فرهنگ</div>
<h1>هنر و فرهنگ</h1>
<h2>بعضی جمع‌ها دور یک داستان شروع می‌شن.</h2>
<p>گاهی یک نمایشگاه، یک اجرای زنده یا یک گفت‌وگوی ساده بهانه‌ای می‌شود برای بیرون آمدن از روزمرگی، دیدن چیزی تازه و پیدا کردن آدم‌هایی که همان چیزهایی را دوست دارند که تو دوست داری.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#events">رویدادها رو ببین ↓</a>
<a class="btn btn-outline" href="#host">رویدادت رو برگزار کن</a>
</div>
</div>
<div class="hero-art" aria-label="فضای تصویری هنر و فرهنگ">
<div class="hero-note">هنر فقط چیزی نیست که می‌بینی؛ بخشی از ماجرا آدم‌هایین که کنارت تجربه‌ش می‌کنن.</div>
</div>
</section>
<section id="events" class="container events">
<div class="section-head">
<div>
<h2>این روزها کجا می‌شه رفت؟</h2>
<p>رویدادهای هنر و فرهنگ که الان یا به‌زودی برگزار می‌شوند.</p>
</div>
<div class="count">۱۵ رویداد</div>
</div>
<div class="filters">
<label class="search">
<span></span>
<input placeholder="دنبال یه اجرا، نمایشگاه یا تجربه خاصی می‌گردی؟">
</label>
<button class="btn btn-outline">فیلترها</button>
</div>
<div class="filter-group" aria-label="فیلترهای سریع">
<button class="chip active">همه</button>
<button class="chip">امروز</button>
<button class="chip">فردا</button>
<button class="chip">آخر هفته</button>
<button class="chip">رایگان</button>
<button class="chip">نزدیک من</button>
</div>
<div class="sort-row">
<span>۱۵ نتیجه پیدا شد</span>
<label>مرتب‌سازی:
<select>
<option>پیشنهاد قبیله</option>
<option>نزدیک‌ترین زمان</option>
<option>جدیدترین</option>
<option>محبوب‌ترین</option>
</select>
</label>
</div>
<div class="grid">
<a class="card" href="#"><div class="thumb t1"><span class="badge">پنجشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">نمایشگاه «شهر بعد از تاریکی»</div><div class="card-desc">نمایشگاه عکاسی شهری</div><div class="meta"><span>۱۸ شهریور · ۱۸:۰۰</span><span>خانه هنرمندان</span></div><div class="card-foot"><span class="price">از ۲۵۰ هزار تومان</span><span class="host">استودیو قاب</span></div></div></a>
<a class="card" href="#"><div class="thumb t2"><span class="badge">جمعه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">شب شعر؛ چند دقیقه بعد از نیمه‌شب</div><div class="card-desc">شعر، موسیقی و یک جمع کوچک</div><div class="meta"><span>۱۹ شهریور · ۲۰:۰۰</span><span>کافه روشن</span></div><div class="card-foot"><span class="price">۱۸۰ هزار تومان</span><span class="host">جمع واژه‌ها</span></div></div></a>
<a class="card" href="#"><div class="thumb t3"><span class="badge">شنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">اجرای زنده؛ صداهای یک اتاق</div><div class="card-desc">اجرای مستقل موسیقی</div><div class="meta"><span>۲۰ شهریور · ۱۹:۳۰</span><span>استودیو شماره ۴</span></div><div class="card-foot"><span class="price">از ۳۲۰ هزار تومان</span><span class="host">خانه صدا</span></div></div></a>
<a class="card" href="#"><div class="thumb t4"><span class="badge">یکشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">یک عصر با سینمای عباس کیارستمی</div><div class="card-desc">تماشا و گفت‌وگو درباره سینما</div><div class="meta"><span>۲۱ شهریور · ۱۷:۰۰</span><span>خانه فیلم</span></div><div class="card-foot"><span class="price">رایگان</span><span class="host">جمع سینما</span></div></div></a>
<a class="card" href="#"><div class="thumb t5"><span class="badge">دوشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">نقاشی بدون بلد بودن</div><div class="card-desc">برای کسانی که فقط می‌خواهند شروع کنند</div><div class="meta"><span>۲۲ شهریور · ۱۸:۳۰</span><span>کارگاه هفت</span></div><div class="card-foot"><span class="price">۲۹۰ هزار تومان</span><span class="host">سارا نیک‌پی</span></div></div></a>
<a class="card" href="#"><div class="thumb t6"><span class="badge">سه‌شنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">گالری‌گردی در مرکز شهر</div><div class="card-desc">سه گالری، یک مسیر و یک عصر متفاوت</div><div class="meta"><span>۲۳ شهریور · ۱۶:۰۰</span><span>مرکز شهر</span></div><div class="card-foot"><span class="price">۲۲۰ هزار تومان</span><span class="host">پیاده‌گرد</span></div></div></a>
<a class="card" href="#"><div class="thumb t7"><span class="badge">چهارشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">پرفورمنس «بدن و شهر»</div><div class="card-desc">یک تجربه نمایشی نزدیک و بی‌واسطه</div><div class="meta"><span>۲۴ شهریور · ۲۰:۳۰</span><span>پلتفرم ۲۸</span></div><div class="card-foot"><span class="price">۳۶۰ هزار تومان</span><span class="host">گروه کژ</span></div></div></a>
<a class="card" href="#"><div class="thumb t8"><span class="badge">پنجشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">داستان‌خوانی؛ روایت‌های ناتمام</div><div class="card-desc">داستان کوتاه، چای و گفت‌وگو</div><div class="meta"><span>۲۵ شهریور · ۱۹:۰۰</span><span>خانه ادبیات</span></div><div class="card-foot"><span class="price">۱۵۰ هزار تومان</span><span class="host">روایت نو</span></div></div></a>
<a class="card" href="#"><div class="thumb t9"><span class="badge">جمعه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">تهران از پشت پنجره‌های قدیمی</div><div class="card-desc">گردش فرهنگی در چند خانه تاریخی</div><div class="meta"><span>۲۶ شهریور · ۱۰:۰۰</span><span>عودلاجان</span></div><div class="card-foot"><span class="price">۳۱۰ هزار تومان</span><span class="host">کوچه‌گرد</span></div></div></a>
<a class="card" href="#"><div class="thumb t10"><span class="badge">شنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">شنیدن یک آلبوم از اول تا آخر</div><div class="card-desc">شنیدن جمعی و گفت‌وگوی بعد از موسیقی</div><div class="meta"><span>۲۷ شهریور · ۲۰:۰۰</span><span>اتاق صدا</span></div><div class="card-foot"><span class="price">۲۰۰ هزار تومان</span><span class="host">موج</span></div></div></a>
<a class="card" href="#"><div class="thumb t11"><span class="badge">یکشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">معماری و حافظه شهر</div><div class="card-desc">یک نشست برای دیدن شهر از زاویه‌ای دیگر</div><div class="meta"><span>۲۸ شهریور · ۱۸:۰۰</span><span>خانه گفتگو</span></div><div class="card-foot"><span class="price">رایگان</span><span class="host">خط شهر</span></div></div></a>
<a class="card" href="#"><div class="thumb t12"><span class="badge">دوشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">سفال برای یک عصر</div><div class="card-desc">ساختن با دست، بدون تجربه قبلی</div><div class="meta"><span>۲۹ شهریور · ۱۷:۳۰</span><span>کارگاه خاک</span></div><div class="card-foot"><span class="price">۴۲۰ هزار تومان</span><span class="host">استودیو خاک</span></div></div></a>
<a class="card" href="#"><div class="thumb t13"><span class="badge">سه‌شنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">نمایش فیلم کوتاه؛ پنج نگاه</div><div class="card-desc">پنج فیلم و گفت‌وگو با سازندگان</div><div class="meta"><span>۳۰ شهریور · ۱۹:۳۰</span><span>سالن کوچک</span></div><div class="card-foot"><span class="price">۲۶۰ هزار تومان</span><span class="host">قاب کوتاه</span></div></div></a>
<a class="card" href="#"><div class="thumb t14"><span class="badge">چهارشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">خط و کاغذ؛ تجربه خوشنویسی آزاد</div><div class="card-desc">یک جلسه آرام برای دست و ذهن</div><div class="meta"><span>۳۱ شهریور · ۱۸:۰۰</span><span>خانه کاغذ</span></div><div class="card-foot"><span class="price">۳۴۰ هزار تومان</span><span class="host">الهام راد</span></div></div></a>
<a class="card" href="#"><div class="thumb t15"><span class="badge">پنجشنبه</span><button class="save"></button><div class="thumb-symbol"></div></div><div class="card-body"><div class="card-title">کتاب‌هایی که ما را عوض کردند</div><div class="card-desc">گفت‌وگو درباره کتاب‌هایی که ماندند</div><div class="meta"><span>۱ مهر · ۱۹:۰۰</span><span>کافه کتاب</span></div><div class="card-foot"><span class="price">رایگان</span><span class="host">جمع خواندن</span></div></div></a>
</div>
<nav class="pagination" aria-label="صفحه‌بندی">
<a class="page" href="#"></a>
<a class="page active" href="#">۱</a>
<a class="page" href="#">۲</a>
<a class="page" href="#">۳</a>
<a class="page" href="#">۴</a>
<a class="page" href="#"></a>
</nav>
</section>
<section id="article" class="article-wrap">
<article class="article">
<div class="article-kicker">بیشتر درباره هنر و فرهنگ</div>
<h2>یک بهانه قدیمی برای دور هم جمع شدن</h2>
<p>خیلی قبل‌تر از گالری، سالن اجرا، سینما و کافه هم آدم‌ها دور داستان، موسیقی، تصویر و روایت جمع می‌شدند. شکل این جمع‌ها عوض شده، اما میل ما به تجربه کردن هنر کنار دیگران هنوز همان است.</p>
<p>امروز ممکن است این جمع دور یک نمایشگاه عکاسی شکل بگیرد، فردا در یک سالن کوچک تئاتر و هفته بعد در یک کافه برای شنیدن شعر، دیدن فیلم یا حرف زدن درباره یک کتاب.</p>
<p>گاهی برای دیدن یک نمایشگاه می‌روی و با هنرمندی آشنا می‌شوی که تا دیروز اسمش را هم نشنیده بودی. گاهی برای یک اجرای زنده بلیط می‌گیری و چند ساعت کنار آدم‌هایی قرار می‌گیری که سلیقه‌ای نزدیک به تو دارند. بعضی وقت‌ها هم فقط از سر کنجکاوی وارد یک جمع می‌شوی و با چیزی روبه‌رو می‌شوی که تا قبل از آن نمی‌شناختی.</p>
<div class="reading-nav">
<strong>توی این بخش می‌خونی:</strong>
<a href="#why">چرا در یک رویداد هنری شرکت کنیم؟</a>
<a href="#alone">اگر تنها باشم چی؟</a>
<a href="#choose">چطور رویداد مناسبم را پیدا کنم؟</a>
</div>
<h3 id="why">چرا در یک رویداد هنری یا فرهنگی شرکت کنیم؟</h3>
<p>قرار نیست همیشه دلیل بزرگی برای بیرون رفتن داشته باشیم. گاهی فقط می‌خواهی شب شبیه شب‌های قبل نباشد؛ چیزی ببینی که قبلاً ندیده‌ای، موسیقی متفاوتی بشنوی، درباره موضوعی حرف بزنی که در زندگی روزمره کمتر فرصت حرف زدن درباره‌اش پیش می‌آید یا با آدم‌هایی روبه‌رو شوی که همان چیزها برایشان جالب است.</p>
<p>رویدادهای هنری و فرهنگی می‌توانند فرصتی باشند برای کشف تجربه‌های تازه، آشنا شدن با آثار و آدم‌های جدید، فاصله گرفتن از برنامه‌های تکراری و پیدا کردن علاقه‌هایی که شاید تا امروز فرصتی برای امتحان کردنشان نداشته‌ای.</p>
<blockquote>بعضی وقت‌ها یک شب خوب فقط با این جمله شروع می‌شه: «بریم ببینیم چه خبره.»</blockquote>
<h3>آیا برای شرکت در رویدادهای هنری باید چیزی از هنر بدانم؟</h3>
<p>نه. برای رفتن به یک نمایشگاه لازم نیست نقاش باشی، برای شرکت در شب شعر لازم نیست شاعر باشی و برای یک نشست سینمایی هم لازم نیست تاریخ سینما را از بر باشی.</p>
<p>بخش زیادی از رویدادهای هنر و فرهنگ برای آدم‌هایی ساخته می‌شوند که فقط علاقه‌مند یا کنجکاوند. اگر یک رویداد نیاز به دانش، مهارت یا پیش‌نیاز خاصی داشته باشد، برگزارکننده آن را در توضیحات رویداد مشخص می‌کند.</p>
<h3 id="alone">اگر تنها باشم، می‌توانم در یک رویداد شرکت کنم؟</h3>
<p>حتماً لازم نیست برای هر تجربه‌ای از قبل همراه داشته باشی. خیلی از رویدادها محیط طبیعی‌تری برای آشنا شدن با آدم‌های تازه هستند، چون کسانی که آنجا حضور دارند از قبل یک نقطه مشترک دارند: همه برای دیدن، شنیدن یا تجربه کردن یک موضوع مشخص آمده‌اند.</p>
<p>لازم نیست حتماً با کسی دوست شوی یا وارد گفت‌وگوی طولانی شوی. حتی حضور در یک جمع تازه، بدون هیچ انتظار دیگری، می‌تواند تجربه متفاوتی باشد.</p>
<h3 id="choose">چطور یک رویداد هنری مناسب خودم پیدا کنم؟</h3>
<p>اول از خودت بپرس این بار دنبال چه حال‌وهوایی هستی. می‌خواهی چیزی ببینی یا چیزی بسازی؟ دنبال یک اجرای زنده‌ای یا یک جمع کوچک برای گفت‌وگو؟ دوست داری چند ساعت آرام در یک نمایشگاه بگردی یا در تجربه‌ای مشارکتی حضور داشته باشی؟</p>
<p>موضوع رویداد، تصاویر، توضیحات برگزارکننده، زمان، محل برگزاری، قیمت و نوع برنامه می‌توانند به انتخاب کمک کنند. اما بعضی وقت‌ها هم لازم نیست انتخاب را خیلی پیچیده کنی.</p>
<h3>رویدادهای کوچک هم ارزش رفتن دارند؟</h3>
<p>بزرگ بودن یک رویداد الزاماً به معنی بهتر بودن آن نیست. گاهی بهترین تجربه‌ها در یک جمع ده یا بیست نفره اتفاق می‌افتند؛ جایی که می‌توانی راحت‌تر با برگزارکننده یا آدم‌های دیگر ارتباط بگیری و حس کنی واقعاً بخشی از آن جمع هستی.</p>
<h3>چرا شناخت برگزارکننده مهم است؟</h3>
<p>برگزارکننده یا همان میزبان، بخش مهمی از تجربه هر رویداد است. او تصمیم گرفته چه آدم‌هایی را دور چه موضوعی جمع کند، برنامه چطور پیش برود و شرکت‌کنندگان قرار است چه چیزی را تجربه کنند.</p>
<p>پیش از انتخاب یک رویداد می‌توانی اطلاعات برگزارکننده، توضیحات برنامه و رویدادهایی را که قبلاً برگزار کرده بررسی کنی تا تصویر روشن‌تری از تجربه پیش رو داشته باشی.</p>
</article>
</section>
<section id="host" class="host-cta">
<div>
<div class="eyebrow" style="color:#f19b7c">برای میزبان‌ها</div>
<h2>این طرف ماجرا تو میزبان باش.</h2>
<p>یک نمایشگاه کوچک، اجرای مستقل، نشست پانزده‌نفره یا یک تجربه تازه؛ برای شروع لازم نیست جمع بزرگی داشته باشی. تو چیزی می‌سازی که ارزش دور هم جمع شدن دارد. قبیله کمک می‌کند آدم‌ها پیدایش کنند.</p>
<div class="host-points">
<div class="host-point">رویدادت رو معرفی کن</div>
<div class="host-point">ثبت‌نام‌ها رو یک‌جا داشته باش</div>
<div class="host-point">بلیط بفروش</div>
<div class="host-point">با شرکت‌کننده‌ها در ارتباط باش</div>
</div>
<a class="btn btn-accent" href="#">رویدادت رو بساز</a>
</div>
<div class="host-art"></div>
</section>
<section class="faq">
<h2>پرسش‌های رایج</h2>
<details open>
<summary>چه برنامه‌هایی در دسته هنر و فرهنگ قرار می‌گیرند؟</summary>
<p>نمایشگاه، گالری‌گردی، تئاتر، موسیقی و اجرای زنده، شب شعر، رونمایی کتاب، نشست فرهنگی، کارگاه هنری، موزه‌گردی و تجربه‌های مستقل و ترکیبی.</p>
</details>
<details>
<summary>برای شرکت در رویداد هنری به تجربه قبلی نیاز دارم؟</summary>
<p>معمولاً نه. اگر یک رویداد پیش‌نیاز خاصی داشته باشد، برگزارکننده آن را در توضیحات مشخص می‌کند.</p>
</details>
<details>
<summary>همه رویدادها بلیط دارند؟</summary>
<p>نه. بعضی رایگان‌اند و بعضی با تهیه بلیط برگزار می‌شوند. شرایط هر رویداد در صفحه خودش مشخص است.</p>
</details>
<details>
<summary>می‌توانم تنها در یک رویداد شرکت کنم؟</summary>
<p>بله. خیلی از آدم‌ها به تنهایی وارد یک رویداد می‌شوند و یک علاقه مشترک می‌تواند شروع طبیعی‌تری برای ارتباط باشد.</p>
</details>
<details>
<summary>رویداد کوچک هم می‌توانم ثبت کنم؟</summary>
<p>بله. یک نشست کوچک، اجرای مستقل یا دورهمی محدود هم می‌تواند یک رویداد باشد.</p>
</details>
</section>
</main>
<footer>
<div class="container">
<div class="footer-grid">
<div>
<h3>قبیله</h3>
<p>جایی که آدم‌ها دور چیزهایی که دوست دارند جمع می‌شوند.</p>
</div>
<div>
<h3>کشف</h3>
<p><a href="#">رویدادها</a></p>
<p><a href="#">برگزارکننده‌ها</a></p>
</div>
<div>
<h3>قبیله</h3>
<p><a href="#">درباره ما</a></p>
<p><a href="#">پشتیبانی</a></p>
</div>
</div>
<div class="copyright">قبیله — همه حقوق محفوظ است.</div>
</div>
</footer>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -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 کن
آخرین به‌روزرسانی لیست: ۱۴۰۵/۰۶/۱۲ (سپتامبر ۲۰۲۶)

View File

@ -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: '<p>متن نمونه مقاله برای تست Playwright.</p>',
}

View File

@ -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<string, unknown>) => 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<string, unknown>
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<string, unknown>) => void) => {
await page.route('**/api/v1/users/me/complete-profile', async (route) => {
const body = (route.request().postDataJSON() ?? {}) as Record<string, unknown>
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 }),
})
}

View File

@ -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<string, unknown> = {}) => ({
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<typeof baseEvent>) => ({
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
}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More