# Game Catalog and One-Shot Showcase — Design Spec

> Issue #39: feat: добавить каталог игр и витрину ваншотов

---

## Goal
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.

---

## Architecture
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.

---

## Tech Stack
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`

---

## Data Model

### New Fields on `sessions` (Migration V027)

| Column | Type | Constraints | Description |
|---|---|---|---|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
| `description` | `TEXT` | nullable | Short description for card |
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |

### `GameSystem` Enum

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

Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.

### DTOs

```csharp
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);

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

public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
```

---

## UI Design

### `/showcase` — Catalog Page

**Layout:**
- Hero with title "Каталог игр"
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
- Pagination (page + pageSize = 12)

**Filters:**
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
- Seats: "Есть места" | "Waitlist" | "Любое"
- System: dropdown with all `GameSystem` values
- Type: "Ваншот" | "Кампания" | "Любое"
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"

**Card Design:**
- Cover image (fallback: colored placeholder with initials)
- Title
- System badge
- Date + time (MSK)
- Duration (e.g. "3 часа")
- Format badge
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
- Club name (link to `/club/{slug}`)
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)

### `/s/{id}` — Public Session Detail (Updated)

New fields added to existing page:
- Cover image (full-width hero)
- System badge
- Description block
- Duration + format
- GM contact (always visible: Telegram username or Discord tag)
- If `allow_direct_registration`:
  - "Записаться" button → Telegram Mini App deeplink or Discord OAuth
  - Direct registration into `session_participants` via `SessionCapacityRules`

---

## Backend

### ISessionStore Methods

```csharp
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
    ShowcaseFilter filter, int page, int pageSize);

Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);

Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
```

`GetShowcaseSessionsAsync` query:
- Cross-group (all clubs with `public_schedule_enabled = true`)
- Only `is_public = true` sessions
- `scheduled_at > now() - interval '4 hours'`
- `status <> 'Cancelled'`
- Apply filters in SQL WHERE clause
- Order by `scheduled_at ASC`
- Offset/limit pagination

`RegisterFromShowcaseAsync`:
- Check `allow_direct_registration = true`
- Load session with `FOR UPDATE`
- Count active + waitlisted participants
- Use `SessionCapacityRules.DecideJoinStatus`
- Insert participant with appropriate `registration_status`
- Return true on success, false if full and no waitlist allowed

---

## Bot Integration

### Telegram Bot

During `CreateSessionCommand` flow, after title/link/time input:
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
3. "Описание игры (краткое):" — text input, optional (skip button)
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
5. "Продолжительность (в часах):" — int input, optional
6. "Обложка (URL или пропустить):" — text input, optional

During `/publish` flow:
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)

### Discord Bot

Same flow adapted for Discord interactions:
- Slash command options or button menus for system/format
- Modal input for description, duration, cover URL
- Fuzzy matching on free-text system input

---

## Migration V027

```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';
```

---

## Testing Strategy

1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution

---

## Version Bump

Issue label: `type:feature` → **minor bump**
Current: `3.3.0` → Next: `3.4.0`

Files to sync:
- `Directory.Build.props`
- `compose.yaml` (bot, discord, web image tags)
- `.gitea/workflows/deploy.yml` (`VERSION` env)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`

---

## Acceptance Criteria (from Issue #39)

- [ ] User can find a published game without accessing a private dashboard
- [ ] Registration does not bypass existing seat/waitlist limits
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
- [ ] Filters work: date, seats, system, type, format
- [ ] GM contact is always visible on public session detail
- [ ] Direct registration respects `SessionCapacityRules`
