Перейти к содержанию

P2-018 Frontend: Публичная страница события

Метаданные

Поле Значение
Фаза Phase 2: Core
Статус done
Приоритет critical
Связь с roadmap Roadmap - Frontend

Контекст

Бизнес-контекст

Публичная страница события — витрина для участников. Показывает информацию о событии, доступные билеты, форму регистрации. Должна быть привлекательной и конвертировать посетителей в участников.

Технический контекст

  • Route group (public) — минимальный layout
  • SEO: metadata, OG tags
  • Server-side rendering для SEO
  • Доступна без авторизации

Существующий код: - events/[slug]/page.tsx — placeholder

Связанные документы: - User Journeys - Journey 2

Цель

Реализовать привлекательную публичную страницу события с информацией и формой регистрации.

Definition of Ready (DoR)

  • [x] Контекст понятен и описан
  • [x] Цель сформулирована
  • [x] Acceptance Criteria определены
  • [x] Технические детали проработаны
  • [x] Зависимости определены
  • [x] Нет блокеров

Acceptance Criteria

Информация о событии

  • [x] Обложка (hero image)
  • [x] Название
  • [x] Дата и время (в timezone события и пользователя)
  • [x] Место (адрес или онлайн)
  • [x] Описание (Markdown rendered)
  • [x] Организатор (название организации)

Типы билетов

  • [x] Список доступных типов
  • [x] Для каждого: название, описание, доступность
  • [x] «Распродан» badge если нет мест
  • [x] «Скоро» если продажи не начались
  • [x] «Закончились» если продажи завершились

Регистрация

  • [x] Выбор типа билета
  • [x] Форма: first_name, last_name, email
  • [x] Custom fields (если настроены)
  • [x] Кнопка «Зарегистрироваться»
  • [x] Если авторизован — поля заполнены автоматически
  • [x] Если не авторизован — предложение войти или продолжить без входа

Видимость участников

  • [x] Если OPEN — показать список зарегистрированных
  • [x] Распределение по типам билетов
  • [x] Скрыть email (показать только имена)

Состояния

  • [x] Событие не опубликовано — 404
  • [x] Событие отменено — сообщение об отмене
  • [x] Событие завершено — «Событие завершилось»
  • [x] Нет билетов — «Регистрация закрыта» + лист ожидания (Phase 3)

SEO

  • [x] Title: «{eventTitle} — AqStream»
  • [x] Description из описания события
  • [x] OG tags для sharing
  • [x] Structured data (Event schema)

Definition of Done (DoD)

  • [x] Все Acceptance Criteria выполнены
  • [x] Server-side rendering
  • [x] Responsive design
  • [x] Fast loading (< 2s)
  • [x] Accessibility basics
  • [x] Code review пройден
  • [ ] CI/CD pipeline проходит

Технические детали

Структура

frontend/app/(public)/events/[slug]/
├── page.tsx              — страница события
├── not-found.tsx         — 404
└── loading.tsx           — skeleton

frontend/components/features/public-event/
├── event-hero.tsx
├── event-info.tsx
├── ticket-selector.tsx
├── registration-form.tsx
└── participants-list.tsx

Server Component

// app/(public)/events/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { eventApi } from '@/lib/api/events';

interface PageProps {
  params: { slug: string };
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const event = await eventApi.getBySlug(params.slug);
  if (!event) return {};

  return {
    title: `${event.title} — AqStream`,
    description: event.description?.slice(0, 160),
    openGraph: {
      title: event.title,
      description: event.description?.slice(0, 160),
      images: event.coverImageUrl ? [event.coverImageUrl] : [],
    },
  };
}

export default async function EventPage({ params }: PageProps) {
  const event = await eventApi.getBySlug(params.slug);

  if (!event || event.status === 'DRAFT') {
    notFound();
  }

  const ticketTypes = await eventApi.getTicketTypes(event.id);

  return (
    <main>
      <EventHero event={event} />
      <div className="container py-8">
        <div className="grid gap-8 lg:grid-cols-3">
          <div className="lg:col-span-2">
            <EventInfo event={event} />
          </div>
          <div>
            <RegistrationCard
              event={event}
              ticketTypes={ticketTypes}
            />
          </div>
        </div>
      </div>
    </main>
  );
}

Registration Form

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useCreateRegistration } from '@/lib/hooks/use-registrations';
import { useAuthStore } from '@/lib/store/auth-store';

const registrationSchema = z.object({
  ticketTypeId: z.string().uuid(),
  firstName: z.string().min(1, 'Имя обязательно'),
  lastName: z.string().min(1, 'Фамилия обязательна'),
  email: z.string().email('Некорректный email'),
});

export function RegistrationForm({ eventId, ticketTypes }) {
  const { user } = useAuthStore();
  const mutation = useCreateRegistration(eventId);

  const form = useForm({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      ticketTypeId: ticketTypes[0]?.id,
      firstName: user?.firstName || '',
      lastName: user?.lastName || '',
      email: user?.email || '',
    },
  });

  async function onSubmit(data) {
    await mutation.mutateAsync(data);
    // Redirect to success page or show ticket
  }

  return (
    <Form {...form}>
      {/* Ticket type selector */}
      {/* Name fields */}
      {/* Email field */}
      <Button type="submit" disabled={mutation.isPending}>
        Зарегистрироваться
      </Button>
    </Form>
  );
}

Зависимости

Блокирует

  • P2-019 Процесс регистрации

Зависит от

Out of Scope

  • Comments / Q&A
  • Social sharing buttons
  • Event recommendations
  • Лист ожидания (Phase 3)

Заметки

  • URL формат: /events/{slug} — SEO friendly
  • Рассмотреть добавление countdown для событий
  • Lazy load registration form (не критично для SEO)
  • Timezone: показывать в timezone события + local