# 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**

```sql
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:
```xml
<ItemGroup>
  <EmbeddedResource Include="Migrations\*.sql" />
</ItemGroup>
```
Run:
```bash
dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
```
Expected: Build succeeds (migration is embedded, not executed at build time).

- [ ] **Step 3: Commit**

```bash
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**

```csharp
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**

```csharp
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**

```csharp
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**

```bash
dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
```
Expected: Build succeeds.

- [ ] **Step 5: Commit**

```bash
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`:

```csharp
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:

```csharp
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**

```csharp
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**

```csharp
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**

```csharp
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**

```bash
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
```
Expected: Build succeeds.

- [ ] **Step 7: Commit**

```bash
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**

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

```bash
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
```
Expected: Build succeeds.

- [ ] **Step 4: Commit**

```bash
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**

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

```bash
dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
```
Expected: Build succeeds.

- [ ] **Step 3: Commit**

```bash
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**

```csharp
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:

```sql
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**

```bash
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**

```bash
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**

```csharp
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**

```csharp
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**

```bash
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**

```bash
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**

```bash
# 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**

```bash
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.
