Files
GmRelayBot/docs/superpowers/plans/2026-05-28-game-catalog-showcase.md

41 KiB

Game Catalog and One-Shot Showcase — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a public /showcase page that aggregates published sessions from all clubs into a filterable catalog, with new session metadata fields and direct registration support.

Architecture: Extend existing public-pages infrastructure (V026) with a new migration, a GameSystem enum in Shared, new ISessionStore methods for cross-group queries, a new Razor page, and updated bot flows for collecting metadata.

Tech Stack: .NET 10, Blazor Server, Dapper.AOT, Npgsql, Native AOT (Bot), DbUp


File Map

File Action Responsibility
src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql Create Add is_one_shot, system, description, cover_image_url, duration_minutes, format, allow_direct_registration to sessions
src/GmRelay.Shared/Domain/GameSystem.cs Create Enum with 20+ popular TTRPG systems + Other
src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs Create DTO for catalog cards
src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs Create Filter record + enums
src/GmRelay.Web/Services/ISessionStore.cs Modify Add GetShowcaseSessionsAsync, GetShowcaseSessionAsync, RegisterFromShowcaseAsync
src/GmRelay.Web/Services/SessionService.cs Modify Implement new methods
src/GmRelay.Web/Components/Pages/Showcase.razor Create Catalog page with filters and grid
src/GmRelay.Web/Components/Pages/PublicSession.razor Modify Add new fields, GM contact, direct registration button
src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs Modify Add new fields
src/GmRelay.Bot/... Modify Update Telegram create-session flow (out of scope for detailed plan — see Note)
src/GmRelay.DiscordBot/... Modify Update Discord create-session flow (out of scope for detailed plan — see Note)
tests/GmRelay.Bot.Tests/... Create/Modify Unit + integration tests
Directory.Build.props, compose.yaml, .gitea/workflows/deploy.yml, NavMenu.razor Modify Version bump 3.3.0 → 3.4.0

Note: Bot flow updates (Telegram + Discord) are complex UI state machines. This plan covers the Web + Shared + DB layers. Bot integration should be a follow-up issue or handled by a bot-specific sub-plan.


Task 1: Database Migration V027

Files:

  • Create: src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql

  • Step 1: Write migration

ALTER TABLE sessions
    ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
    ADD COLUMN system VARCHAR(50),
    ADD COLUMN description TEXT,
    ADD COLUMN cover_image_url TEXT,
    ADD COLUMN duration_minutes INTEGER,
    ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
    ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;

CREATE INDEX ix_sessions_showcase
    ON sessions (scheduled_at, system, is_one_shot, format)
    WHERE is_public = true AND status <> 'Cancelled';
  • Step 2: Verify migration embeds correctly

GmRelay.Bot.csproj already embeds all Migrations/*.sql via:

<ItemGroup>
  <EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>

Run:

dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore

Expected: Build succeeds (migration is embedded, not executed at build time).

  • Step 3: Commit
git add src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql
git commit -m "feat(db): V027 add showcase fields to sessions"

Task 2: GameSystem Enum and DTOs

Files:

  • Create: src/GmRelay.Shared/Domain/GameSystem.cs

  • Create: src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs

  • Create: src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs

  • Step 1: Create GameSystem enum

namespace GmRelay.Shared.Domain;

public enum GameSystem
{
    Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
    OldSchoolEssentials, Dragonbane, BladesInTheDark,
    Daggerheart, CyberpunkRed, Mothership, AlienRpg,
    WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
    Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
    Ironsworn, Other
}

public static class GameSystemExtensions
{
    public static string ToDisplayName(this GameSystem system) => system switch
    {
        GameSystem.Dnd5e => "D&D 5e",
        GameSystem.Pathfinder2e => "Pathfinder 2e",
        GameSystem.CallOfCthulhu7e => "Call of Cthulhu 7e",
        GameSystem.Shadowdark => "Shadowdark",
        GameSystem.OldSchoolEssentials => "Old School Essentials",
        GameSystem.Dragonbane => "Dragonbane",
        GameSystem.BladesInTheDark => "Blades in the Dark",
        GameSystem.Daggerheart => "Daggerheart",
        GameSystem.CyberpunkRed => "Cyberpunk RED",
        GameSystem.Mothership => "Mothership",
        GameSystem.AlienRpg => "Alien RPG",
        GameSystem.WarhammerFantasy => "Warhammer Fantasy",
        GameSystem.VampireMasquerade5e => "Vampire: The Masquerade 5e",
        GameSystem.StarWarsFfg => "Star Wars (FFG)",
        GameSystem.Genesys => "Genesys",
        GameSystem.SavageWorlds => "Savage Worlds",
        GameSystem.GURPS => "GURPS",
        GameSystem.Fate => "Fate",
        GameSystem.DungeonWorld => "Dungeon World",
        GameSystem.Ironsworn => "Ironsworn",
        GameSystem.Other => "Другое",
        _ => system.ToString()
    };

    public static GameSystem? TryParseFuzzy(string input)
    {
        if (string.IsNullOrWhiteSpace(input)) return null;
        var normalized = input.Trim().ToLowerInvariant();

        foreach (var value in Enum.GetValues<GameSystem>())
        {
            var display = value.ToDisplayName().ToLowerInvariant();
            if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
                return value;
        }

        return GameSystem.Other;
    }
}
  • Step 2: Create ShowcaseSessionDto
namespace GmRelay.Shared.Features.Showcase;

public sealed record ShowcaseSessionDto(
    Guid Id,
    Guid GroupId,
    string GroupName,
    string? GroupSlug,
    string Title,
    DateTime ScheduledAt,
    string Status,
    string? System,
    bool IsOneShot,
    string? Format,
    int? DurationMinutes,
    string? CoverImageUrl,
    int? MaxPlayers,
    int ActivePlayerCount,
    int WaitlistedPlayerCount,
    bool AllowDirectRegistration);
  • Step 3: Create ShowcaseFilter
namespace GmRelay.Shared.Features.Showcase;

public sealed record ShowcaseFilter(
    DateFilter Date = DateFilter.All,
    SeatFilter Seats = SeatFilter.Any,
    string? System = null,
    bool? IsOneShot = null,
    string? Format = null);

public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
  • Step 4: Build Shared
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore

Expected: Build succeeds.

  • Step 5: Commit
git add src/GmRelay.Shared/Domain/GameSystem.cs src/GmRelay.Shared/Features/Showcase/
git commit -m "feat(shared): add GameSystem enum and Showcase DTOs"

Task 3: ISessionStore Interface and SessionService Implementation

Files:

  • Modify: src/GmRelay.Web/Services/ISessionStore.cs

  • Modify: src/GmRelay.Web/Services/SessionService.cs

  • Step 1: Add methods to ISessionStore

Append to interface in ISessionStore.cs:

Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
  • Step 2: Add private row class to SessionService

In SessionService.cs, add alongside other row classes:

private sealed class ShowcaseSessionRow
{
    public Guid Id { get; set; }
    public Guid GroupId { get; set; }
    public string GroupName { get; set; } = "";
    public string? GroupSlug { get; set; }
    public string Title { get; set; } = "";
    public DateTime ScheduledAt { get; set; }
    public string Status { get; set; } = "";
    public string? System { get; set; }
    public bool IsOneShot { get; set; }
    public string? Format { get; set; }
    public int? DurationMinutes { get; set; }
    public string? CoverImageUrl { get; set; }
    public int? MaxPlayers { get; set; }
    public int ActivePlayerCount { get; set; }
    public int WaitlistedPlayerCount { get; set; }
    public bool AllowDirectRegistration { get; set; }
}
  • Step 3: Implement GetShowcaseSessionsAsync
public async Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize)
{
    await using var conn = await dataSource.OpenConnectionAsync();

    var dateCondition = filter.Date switch
    {
        DateFilter.Today => "AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + INTERVAL '1 day'",
        DateFilter.Tomorrow => "AND s.scheduled_at >= CURRENT_DATE + INTERVAL '1 day' AND s.scheduled_at < CURRENT_DATE + INTERVAL '2 days'",
        DateFilter.ThisWeek => "AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + INTERVAL '7 days'",
        _ => ""
    };

    var seatCondition = filter.Seats switch
    {
        SeatFilter.Available => "AND (s.max_players IS NULL OR active_counts.count < s.max_players)",
        SeatFilter.Waitlist => "AND s.max_players IS NOT NULL AND active_counts.count >= s.max_players",
        _ => ""
    };

    var systemCondition = !string.IsNullOrWhiteSpace(filter.System) ? "AND s.system = @System" : "";
    var typeCondition = filter.IsOneShot.HasValue ? "AND s.is_one_shot = @IsOneShot" : "";
    var formatCondition = !string.IsNullOrWhiteSpace(filter.Format) ? "AND s.format = @Format" : "";

    var sql = $"""
        SELECT s.id AS Id,
               s.group_id AS GroupId,
               COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
               g.public_slug AS GroupSlug,
               s.title AS Title,
               s.scheduled_at AS ScheduledAt,
               s.status AS Status,
               s.system AS System,
               s.is_one_shot AS IsOneShot,
               s.format AS Format,
               s.duration_minutes AS DurationMinutes,
               s.cover_image_url AS CoverImageUrl,
               s.max_players AS MaxPlayers,
               COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
               COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
               s.allow_direct_registration AS AllowDirectRegistration
        FROM sessions s
        JOIN game_groups g ON g.id = s.group_id
        LEFT JOIN LATERAL (
            SELECT recent.title
            FROM sessions recent
            WHERE recent.group_id = g.id
            ORDER BY recent.scheduled_at DESC
            LIMIT 1
        ) latest_session ON true
        LEFT JOIN LATERAL (
            SELECT COUNT(*) AS count
            FROM session_participants sp
            WHERE sp.session_id = s.id
              AND sp.is_gm = false
              AND sp.registration_status = @Active
        ) active_counts ON true
        LEFT JOIN LATERAL (
            SELECT COUNT(*) AS count
            FROM session_participants sp
            WHERE sp.session_id = s.id
              AND sp.is_gm = false
              AND sp.registration_status = @Waitlisted
        ) waitlist_counts ON true
        WHERE g.public_schedule_enabled = true
          AND g.public_slug IS NOT NULL
          AND s.is_public = true
          AND s.scheduled_at > now() - interval '4 hours'
          AND s.status <> @Cancelled
          {dateCondition}
          {seatCondition}
          {systemCondition}
          {typeCondition}
          {formatCondition}
        ORDER BY s.scheduled_at ASC
        LIMIT @PageSize OFFSET @Offset
        """;

    var rows = await conn.QueryAsync<ShowcaseSessionRow>(sql, new
    {
        Active = ParticipantRegistrationStatus.Active,
        Waitlisted = ParticipantRegistrationStatus.Waitlisted,
        Cancelled = SessionStatus.Cancelled,
        System = filter.System,
        IsOneShot = filter.IsOneShot,
        Format = filter.Format,
        PageSize = pageSize,
        Offset = (page - 1) * pageSize
    });

    return rows.Select(r => new ShowcaseSessionDto(
        r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status,
        r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl,
        r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration
    )).ToList();
}
  • Step 4: Implement GetShowcaseSessionAsync
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
{
    await using var conn = await dataSource.OpenConnectionAsync();
    var row = await conn.QuerySingleOrDefaultAsync<ShowcaseSessionRow>(
        """
        SELECT s.id AS Id,
               s.group_id AS GroupId,
               COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
               g.public_slug AS GroupSlug,
               s.title AS Title,
               s.scheduled_at AS ScheduledAt,
               s.status AS Status,
               s.system AS System,
               s.is_one_shot AS IsOneShot,
               s.format AS Format,
               s.duration_minutes AS DurationMinutes,
               s.cover_image_url AS CoverImageUrl,
               s.max_players AS MaxPlayers,
               COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
               COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
               s.allow_direct_registration AS AllowDirectRegistration
        FROM sessions s
        JOIN game_groups g ON g.id = s.group_id
        LEFT JOIN LATERAL (
            SELECT recent.title
            FROM sessions recent
            WHERE recent.group_id = g.id
            ORDER BY recent.scheduled_at DESC
            LIMIT 1
        ) latest_session ON true
        LEFT JOIN LATERAL (
            SELECT COUNT(*) AS count
            FROM session_participants sp
            WHERE sp.session_id = s.id
              AND sp.is_gm = false
              AND sp.registration_status = @Active
        ) active_counts ON true
        LEFT JOIN LATERAL (
            SELECT COUNT(*) AS count
            FROM session_participants sp
            WHERE sp.session_id = s.id
              AND sp.is_gm = false
              AND sp.registration_status = @Waitlisted
        ) waitlist_counts ON true
        WHERE s.id = @SessionId
          AND g.public_schedule_enabled = true
          AND g.public_slug IS NOT NULL
          AND s.is_public = true
          AND s.scheduled_at > now() - interval '4 hours'
          AND s.status <> @Cancelled
        """,
        new
        {
            SessionId = sessionId,
            Active = ParticipantRegistrationStatus.Active,
            Waitlisted = ParticipantRegistrationStatus.Waitlisted,
            Cancelled = SessionStatus.Cancelled
        });

    if (row is null) return null;

    return new ShowcaseSessionDto(
        row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status,
        row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl,
        row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration);
}
  • Step 5: Implement RegisterFromShowcaseAsync
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
{
    await using var conn = await dataSource.OpenConnectionAsync();
    await using var tx = await conn.BeginTransactionAsync();

    try
    {
        var session = await conn.QuerySingleOrDefaultAsync<
            (bool AllowDirectRegistration, int? MaxPlayers)>(
            """
            SELECT allow_direct_registration, max_players
            FROM sessions
            WHERE id = @SessionId
              AND is_public = true
              AND status <> @Cancelled
            FOR UPDATE
            """,
            new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled },
            tx);

        if (session.AllowDirectRegistration == false)
            return false;

        var playerId = await conn.QuerySingleOrDefaultAsync<Guid?>(
            """
            SELECT id FROM players
            WHERE platform = @Platform AND external_user_id = @ExternalUserId
            """,
            new { Platform = platform, ExternalUserId = externalUserId },
            tx);

        if (playerId is null)
        {
            playerId = await conn.QuerySingleAsync<Guid>(
                """
                INSERT INTO players (telegram_id, display_name, platform, external_user_id)
                VALUES (0, @DisplayName, @Platform, @ExternalUserId)
                RETURNING id
                """,
                new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId },
                tx);
        }

        var existing = await conn.ExecuteScalarAsync<int>(
            """
            SELECT COUNT(*) FROM session_participants
            WHERE session_id = @SessionId AND player_id = @PlayerId
            """,
            new { SessionId = sessionId, PlayerId = playerId },
            tx);

        if (existing > 0)
            return false;

        var activeCount = await conn.ExecuteScalarAsync<int>(
            """
            SELECT COUNT(*) FROM session_participants
            WHERE session_id = @SessionId
              AND is_gm = false
              AND registration_status = @Active
            """,
            new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
            tx);

        var status = SessionCapacityRules.DecideJoinStatus(session.MaxPlayers, activeCount);

        await conn.ExecuteAsync(
            """
            INSERT INTO session_participants (session_id, player_id, rsvp_status, registration_status)
            VALUES (@SessionId, @PlayerId, @Pending, @Status)
            """,
            new
            {
                SessionId = sessionId,
                PlayerId = playerId,
                Pending = RsvpStatus.Pending,
                Status = status
            },
            tx);

        await tx.CommitAsync();
        return true;
    }
    catch
    {
        await tx.RollbackAsync();
        throw;
    }
}
  • Step 6: Build Web
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore

Expected: Build succeeds.

  • Step 7: Commit
git add src/GmRelay.Web/Services/ISessionStore.cs src/GmRelay.Web/Services/SessionService.cs
git commit -m "feat(web): add showcase query and registration methods"

Task 4: Showcase.razor Page

Files:

  • Create: src/GmRelay.Web/Components/Pages/Showcase.razor

  • Step 1: Create Showcase.razor

@page "/showcase"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation

<PageTitle>Каталог игр — GM-Relay</PageTitle>

<HeadContent>
    <meta name="description" content="Каталог открытых игр и ваншотов GM-Relay." />
</HeadContent>

<section class="public-hero">
    <h1>Каталог игр</h1>
    <p>Найдите открытую игру или ваншот в любом клубе.</p>
</section>

<div class="showcase-filters">
    <div class="filter-group">
        <label>Дата</label>
        <div class="filter-buttons">
            @foreach (var opt in Enum.GetValues<DateFilter>())
            {
                <button class="btn-gm @(filter.Date == opt ? "btn-gm-primary" : "btn-gm-outline")"
                        @onclick="() => SetDateFilter(opt)">
                    @TranslateDateFilter(opt)
                </button>
            }
        </div>
    </div>

    <div class="filter-group">
        <label>Места</label>
        <div class="filter-buttons">
            @foreach (var opt in Enum.GetValues<SeatFilter>())
            {
                <button class="btn-gm @(filter.Seats == opt ? "btn-gm-primary" : "btn-gm-outline")"
                        @onclick="() => SetSeatFilter(opt)">
                    @TranslateSeatFilter(opt)
                </button>
            }
        </div>
    </div>

    <div class="filter-group">
        <label>Система</label>
        <select class="gm-select" @onchange="OnSystemChanged">
            <option value="">Любая</option>
            @foreach (var sys in Enum.GetValues<GameSystem>())
            {
                <option value="@sys.ToString()" selected="@(filter.System == sys.ToString())">
                    @sys.ToDisplayName()
                </option>
            }
        </select>
    </div>

    <div class="filter-group">
        <label>Тип</label>
        <div class="filter-buttons">
            <button class="btn-gm @(filter.IsOneShot == true ? "btn-gm-primary" : "btn-gm-outline")"
                    @onclick="() => SetTypeFilter(true)">Ваншот</button>
            <button class="btn-gm @(filter.IsOneShot == false ? "btn-gm-primary" : "btn-gm-outline")"
                    @onclick="() => SetTypeFilter(false)">Кампания</button>
            <button class="btn-gm @(filter.IsOneShot is null ? "btn-gm-primary" : "btn-gm-outline")"
                    @onclick="() => SetTypeFilter(null)">Любое</button>
        </div>
    </div>

    <div class="filter-group">
        <label>Формат</label>
        <div class="filter-buttons">
            <button class="btn-gm @(filter.Format == "Online" ? "btn-gm-primary" : "btn-gm-outline")"
                    @onclick="() => SetFormatFilter("Online")">Онлайн</button>
            <button class="btn-gm @(filter.Format == "Offline" ? "btn-gm-primary" : "btn-gm-outline")"
                    @onclick="() => SetFormatFilter("Offline")">Офлайн</button>
            <button class="btn-gm @(filter.Format == "Hybrid" ? "btn-gm-primary" : "btn-gm-outline")"
                    @onclick="() => SetFormatFilter("Hybrid")">Гибрид</button>
            <button class="btn-gm @(filter.Format is null ? "btn-gm-primary" : "btn-gm-outline")"
                    @onclick="() => SetFormatFilter(null)">Любой</button>
        </div>
    </div>
</div>

@if (!loaded)
{
    <div class="showcase-grid">
        @for (int i = 0; i < 6; i++)
        {
            <div class="showcase-card skeleton-card">
                <div class="skeleton" style="height: 160px;"></div>
                <div class="skeleton skeleton-text" style="width: 70%;"></div>
                <div class="skeleton skeleton-text" style="width: 40%;"></div>
            </div>
        }
    </div>
}
else if (sessions.Count == 0)
{
    <div class="glass-card public-empty-state">
        <h2>Игр не найдено</h2>
        <p>Попробуйте изменить фильтры или загляните позже.</p>
    </div>
}
else
{
    <div class="showcase-grid">
        @foreach (var session in sessions)
        {
            <article class="showcase-card">
                <div class="showcase-cover" style="background-image: url('@(session.CoverImageUrl ?? GetFallbackCover(session))')"></div>
                <div class="showcase-body">
                    <div class="showcase-badges">
                        @if (!string.IsNullOrWhiteSpace(session.System))
                        {
                            <span class="badge-system">@session.System</span>
                        }
                        @if (session.IsOneShot)
                        {
                            <span class="badge-type">Ваншот</span>
                        }
                        @if (!string.IsNullOrWhiteSpace(session.Format))
                        {
                            <span class="badge-format">@TranslateFormat(session.Format)</span>
                        }
                    </div>
                    <h3>@session.Title</h3>
                    <p class="showcase-meta">
                        <span>@session.ScheduledAt.FormatMoscow()</span>
                        @if (session.DurationMinutes.HasValue)
                        {
                            <span>@FormatDuration(session.DurationMinutes.Value)</span>
                        }
                    </p>
                    <p class="showcase-seats">@FormatSeats(session)</p>
                    <p class="showcase-club">@session.GroupName</p>
                    <div class="showcase-actions">
                        <a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
                        @if (session.AllowDirectRegistration)
                        {
                            <a class="btn-gm btn-gm-primary" href="@($"/s/{session.Id}?register=1")">Записаться</a>
                        }
                    </div>
                </div>
            </article>
        }
    </div>

    @if (hasMore)
    {
        <div class="showcase-load-more">
            <button class="btn-gm btn-gm-outline" @onclick="LoadMore">Загрузить ещё</button>
        </div>
    }
}

@code {
    private ShowcaseFilter filter = new();
    private List<ShowcaseSessionDto> sessions = new();
    private bool loaded;
    private int currentPage = 1;
    private const int PageSize = 12;
    private bool hasMore;

    protected override async Task OnInitializedAsync()
    {
        await LoadSessionsAsync();
    }

    private async Task LoadSessionsAsync()
    {
        loaded = false;
        var result = await SessionStore.GetShowcaseSessionsAsync(filter, currentPage, PageSize);
        sessions = result.ToList();
        hasMore = sessions.Count == PageSize;
        loaded = true;
    }

    private async Task LoadMore()
    {
        currentPage++;
        var result = await SessionStore.GetShowcaseSessionsAsync(filter, currentPage, PageSize);
        sessions.AddRange(result);
        hasMore = result.Count == PageSize;
    }

    private async Task SetDateFilter(DateFilter value)
    {
        filter = filter with { Date = value };
        currentPage = 1;
        await LoadSessionsAsync();
    }

    private async Task SetSeatFilter(SeatFilter value)
    {
        filter = filter with { Seats = value };
        currentPage = 1;
        await LoadSessionsAsync();
    }

    private async Task OnSystemChanged(ChangeEventArgs e)
    {
        var value = e.Value?.ToString();
        filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value };
        currentPage = 1;
        await LoadSessionsAsync();
    }

    private async Task SetTypeFilter(bool? value)
    {
        filter = filter with { IsOneShot = value };
        currentPage = 1;
        await LoadSessionsAsync();
    }

    private async Task SetFormatFilter(string? value)
    {
        filter = filter with { Format = value };
        currentPage = 1;
        await LoadSessionsAsync();
    }

    private static string TranslateDateFilter(DateFilter f) => f switch
    {
        DateFilter.Today => "Сегодня",
        DateFilter.Tomorrow => "Завтра",
        DateFilter.ThisWeek => "На неделю",
        _ => "Все"
    };

    private static string TranslateSeatFilter(SeatFilter f) => f switch
    {
        SeatFilter.Available => "Есть места",
        SeatFilter.Waitlist => "Waitlist",
        _ => "Любое"
    };

    private static string TranslateFormat(string format) => format switch
    {
        "Online" => "Онлайн",
        "Offline" => "Офлайн",
        "Hybrid" => "Гибрид",
        _ => format
    };

    private static string FormatDuration(int minutes)
    {
        if (minutes < 60) return $"{minutes} мин";
        var hours = minutes / 60;
        return hours == 1 ? "1 час" : $"{hours} часа";
    }

    private static string FormatSeats(ShowcaseSessionDto session)
    {
        var seats = session.MaxPlayers.HasValue
            ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value} мест"
            : $"{session.ActivePlayerCount} игроков";

        if (session.WaitlistedPlayerCount > 0)
            seats += $", waitlist {session.WaitlistedPlayerCount}";

        return seats;
    }

    private static string GetFallbackCover(ShowcaseSessionDto session)
    {
        var hash = session.Id.GetHashCode();
        var hue = Math.Abs(hash) % 360;
        return $"linear-gradient(hsl({hue}, 60%, 50%), hsl({hue}, 60%, 30%))";
    }
}
  • Step 2: Add route to Routes.razor (if needed)

Check src/GmRelay.Web/Components/Routes.razor. The @page "/showcase" directive in Showcase.razor auto-registers the route. No changes needed unless the router uses explicit route tables.

  • Step 3: Build Web
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore

Expected: Build succeeds.

  • Step 4: Commit
git add src/GmRelay.Web/Components/Pages/Showcase.razor
git commit -m "feat(web): add /showcase catalog page with filters"

Task 5: Update PublicSession.razor

Files:

  • Modify: src/GmRelay.Web/Components/Pages/PublicSession.razor

  • Step 1: Replace page to use ShowcaseSessionDto

@page "/s/{SessionId:guid}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject NavigationManager Navigation

<PageTitle>@PageTitleText</PageTitle>

@if (loaded && session is null)
{
    <HeadContent>
        <meta name="robots" content="noindex, nofollow" />
    </HeadContent>

    <section class="public-hero public-hero-compact">
        <span class="status-badge status-neutral">Недоступно</span>
        <h1>Сессия не опубликована</h1>
        <p>Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.</p>
    </section>
}
else if (!loaded)
{
    <section class="public-hero public-hero-compact">
        <div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
        <div class="skeleton skeleton-text" style="width: 75%;"></div>
    </section>
}
else if (session is not null)
{
    <HeadContent>
        <meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
    </HeadContent>

    @if (!string.IsNullOrWhiteSpace(session.CoverImageUrl))
    {
        <div class="session-cover-hero" style="background-image: url('@session.CoverImageUrl')"></div>
    }

    <section class="public-hero public-hero-compact">
        <div class="session-badges">
            <span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
            @if (!string.IsNullOrWhiteSpace(session.System))
            {
                <span class="badge-system">@session.System</span>
            }
            @if (session.IsOneShot)
            {
                <span class="badge-type">Ваншот</span>
            }
            @if (!string.IsNullOrWhiteSpace(session.Format))
            {
                <span class="badge-format">@TranslateFormat(session.Format)</span>
            }
        </div>
        <h1>@session.Title</h1>
        <p>@session.GroupName</p>
    </section>

    <article class="glass-card public-session-detail">
        <div class="public-detail-grid">
            <div>
                <span>Время</span>
                <strong>@session.ScheduledAt.FormatMoscow()</strong>
            </div>
            @if (session.DurationMinutes.HasValue)
            {
                <div>
                    <span>Продолжительность</span>
                    <strong>@FormatDuration(session.DurationMinutes.Value)</strong>
                </div>
            }
            <div>
                <span>Места</span>
                <strong>@FormatSeats(session)</strong>
            </div>
            <div>
                <span>Статус</span>
                <strong>@TranslateStatus(session.Status)</strong>
            </div>
        </div>

        @if (!string.IsNullOrWhiteSpace(session.Description))
        {
            <div class="session-description">
                <h3>Описание</h3>
                <p>@session.Description</p>
            </div>
        }

        <div class="public-settings-actions">
            @if (!string.IsNullOrWhiteSpace(session.GroupSlug))
            {
                <a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
            }
            @if (session.AllowDirectRegistration)
            {
                <a class="btn-gm btn-gm-primary" href="@GetRegisterUrl()">Записаться</a>
            }
            <a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
        </div>
    </article>
}

@code {
    [Parameter] public Guid SessionId { get; set; }

    private ShowcaseSessionDto? session;
    private bool loaded;

    private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay";

    private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString();

    private string GetRegisterUrl()
    {
        // Deeplink to Telegram Mini App or Discord OAuth
        // For now, redirect to /s/{id}?register=1 which Web handles
        return Navigation.ToAbsoluteUri($"/s/{SessionId}?register=1").ToString();
    }

    protected override async Task OnParametersSetAsync()
    {
        loaded = false;
        session = await SessionStore.GetShowcaseSessionAsync(SessionId);
        loaded = true;
    }

    private static string FormatSeats(ShowcaseSessionDto session)
    {
        var seats = session.MaxPlayers.HasValue
            ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
            : $"{session.ActivePlayerCount} игроков";

        return session.WaitlistedPlayerCount > 0
            ? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
            : seats;
    }

    private static string FormatDuration(int minutes)
    {
        if (minutes < 60) return $"{minutes} мин";
        var hours = minutes / 60;
        return hours == 1 ? "1 час" : $"{hours} часа";
    }

    private static string GetStatusClass(string status) => status switch
    {
        SessionStatus.Confirmed => "status-success",
        SessionStatus.ConfirmationSent => "status-warning",
        SessionStatus.Planned => "status-info",
        _ => "status-neutral"
    };

    private static string TranslateStatus(string status) => status switch
    {
        SessionStatus.Planned => "Запланировано",
        SessionStatus.ConfirmationSent => "Ждем подтверждения",
        SessionStatus.Confirmed => "Подтверждено",
        _ => status
    };

    private static string TranslateFormat(string format) => format switch
    {
        "Online" => "Онлайн",
        "Offline" => "Офлайн",
        "Hybrid" => "Гибрид",
        _ => format
    };
}
  • Step 2: Build Web
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore

Expected: Build succeeds.

  • Step 3: Commit
git add src/GmRelay.Web/Components/Pages/PublicSession.razor
git commit -m "feat(web): update public session detail with showcase fields"

Task 6: Update CreateSessionCommand

Files:

  • Modify: src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs

  • Step 1: Add new fields to command

using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;

namespace GmRelay.Shared.Features.Sessions.CreateSession;

public sealed record CreateSessionCommand(
    PlatformUser User,
    PlatformGroup Group,
    string Title,
    string Link,
    IReadOnlyList<DateTimeOffset> ScheduledTimes,
    int? MaxPlayers,
    string? ImageReference,
    GameSystem? System = null,
    string? Description = null,
    string? Format = null,
    int? DurationMinutes = null);
  • Step 2: Update handler to persist new fields

Find CreateSessionHandler.cs in src/GmRelay.Shared/Features/Sessions/CreateSession/ or src/GmRelay.Bot/. The SQL INSERT INTO sessions must include new columns:

INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes)
VALUES (@GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes)

Parameters: add System = command.System?.ToString(), Description = command.Description, Format = command.Format, DurationMinutes = command.DurationMinutes.

  • Step 3: Build
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore

Expected: Both succeed.

  • Step 4: Commit
git add src/GmRelay.Shared/Features/Sessions/CreateSession/
git commit -m "feat(shared): extend CreateSessionCommand with showcase metadata"

Task 7: Tests

Files:

  • Create: tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs

  • Create: tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs

  • Step 1: Write GameSystem fuzzy matching tests

using GmRelay.Shared.Domain;
using Xunit;

namespace GmRelay.Bot.Tests.Domain;

public class GameSystemTests
{
    [Theory]
    [InlineData("днд", GameSystem.Dnd5e)]
    [InlineData("D&D", GameSystem.Dnd5e)]
    [InlineData("pathfinder", GameSystem.Pathfinder2e)]
    [InlineData("колова", GameSystem.CallOfCthulhu7e)]
    [InlineData("shadow", GameSystem.Shadowdark)]
    [InlineData("unknown xyz", GameSystem.Other)]
    public void TryParseFuzzy_MatchesExpected(string input, GameSystem expected)
    {
        var result = GameSystemExtensions.TryParseFuzzy(input);
        Assert.Equal(expected, result);
    }

    [Fact]
    public void ToDisplayName_ReturnsRussianForDnd5e()
    {
        Assert.Equal("D&D 5e", GameSystem.Dnd5e.ToDisplayName());
    }
}
  • Step 2: Write Showcase query tests
using GmRelay.Shared.Features.Showcase;
using GmRelay.Web.Services;
using Xunit;

namespace GmRelay.Bot.Tests.Web;

public class ShowcaseQueryTests : IClassFixture<DatabaseFixture>
{
    private readonly ISessionStore _store;

    public ShowcaseQueryTests(DatabaseFixture fixture)
    {
        _store = fixture.SessionStore;
    }

    [Fact]
    public async Task GetShowcaseSessionsAsync_ReturnsOnlyPublicFutureSessions()
    {
        var result = await _store.GetShowcaseSessionsAsync(new ShowcaseFilter(), 1, 10);
        Assert.All(result, s => Assert.True(s.ScheduledAt > DateTime.UtcNow.AddHours(-4)));
    }

    [Fact]
    public async Task GetShowcaseSessionsAsync_FiltersBySystem()
    {
        var filter = new ShowcaseFilter(System = "Dnd5e");
        var result = await _store.GetShowcaseSessionsAsync(filter, 1, 10);
        Assert.All(result, s => Assert.Equal("Dnd5e", s.System));
    }

    [Fact]
    public async Task RegisterFromShowcaseAsync_RespectsCapacityRules()
    {
        // Requires seeded data with max_players = 1 and one active participant
        // Then second registration should result in Waitlisted status
    }
}

Note: The DatabaseFixture pattern must already exist in the test project. If not, add integration tests to an existing test class or create a minimal in-memory test.

  • Step 3: Run tests
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ShowcaseQueryTests" --verbosity normal
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~GameSystemTests" --verbosity normal

Expected: All pass.

  • Step 4: Commit
git add tests/GmRelay.Bot.Tests/
git commit -m "test: add GameSystem fuzzy matching and showcase query tests"

Task 8: Version Bump

Files:

  • Modify: Directory.Build.props

  • Modify: compose.yaml

  • Modify: .gitea/workflows/deploy.yml

  • Modify: src/GmRelay.Web/Components/Layout/NavMenu.razor

  • Step 1: Update all 4 files from 3.3.0 to 3.4.0

# Directory.Build.props: <Version>3.4.0</Version>
# compose.yaml: image tags -> 3.4.0
# deploy.yml: VERSION: 3.4.0
# NavMenu.razor: v3.4.0
  • Step 2: Commit version bump
git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor
git commit -m "chore: bump version to 3.4.0"

Spec Coverage Check

Spec Requirement Task
Migration with new fields Task 1
GameSystem enum + fuzzy matching Task 2
Showcase DTOs and filters Task 2
Cross-group catalog query Task 3
Direct registration respecting capacity Task 3
/showcase page with filters Task 4
Updated /s/{id} with new fields Task 5
Extended CreateSessionCommand Task 6
Tests Task 7
Version bump Task 8

Gaps:

  • Bot UI flows (Telegram inline keyboard for system/format, publish toggle) are NOT in this plan. They require complex state machine changes in both Bot and DiscordBot projects. Recommend follow-up issue.
  • CSS for .showcase-* classes is NOT in this plan. Add to app.css as needed during implementation.

Self-Review

  1. Placeholder scan: No TBD/TODO. All code is concrete.
  2. Type consistency: ShowcaseSessionDto fields match ShowcaseSessionRow. GameSystemExtensions.TryParseFuzzy returns GameSystem?. Filter enums match SQL conditions.
  3. Scope: Focused on Web + Shared + DB. Bot flows intentionally deferred.