6cb2fbe610
PR Checks / test-and-build (pull_request) Successful in 7m28s
Implements Issue #110: game masters can now publish sessions exclusively to a club's private showcase, gated behind a member application and approval flow. Adds a 4-state publication_mode (None/Catalog/ClubOnly/Both) replacing the binary is_public, plus a club_memberships table with Pending/Active/Rejected/Left lifecycle and partial unique index ensuring a single Active row per (group, player). Highlights - V030 migration: club_memberships, publication_mode, drop is_public, recreate partial indexes, portfolio_games gains publication_mode. - PublicationMode enum + extensions in GmRelay.Shared. - ISessionStore gains 12 membership/showcase methods; AuthorizedMembershipService owns the membership flow with GM-only approve/reject authorization. - PublicClub / PublicMasterProfile / PublicSession: member- aware queries (ClubOnly visible only to Active members). - New pages: MyClubMemberships (/profile/memberships) and ClubApplications (/group/{id}/applications). - GroupDetails and EditSession switch from a bool toggle to a 4-state publication_mode selector. - NavMenu adds Moji kluby, PublicLayout adds Kluby. Tests: 4 new test files (PublicationMode, ClubMemberships, AuthorizedMembershipService, ClubShowcaseSource) + updates to PublicClubPages, AuthorizedSessionService/Portfolio service FakeSessionStore, CampaignTemplatesNavigation. 493 tests pass. Bump version 3.6.0 -> 3.7.0 across Directory.Build.props, compose.yaml, deploy.yml, NavMenu.razor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
148 lines
5.9 KiB
Plaintext
148 lines
5.9 KiB
Plaintext
@page "/profile/memberships"
|
||
@using GmRelay.Web.Services
|
||
@using Microsoft.AspNetCore.Authorization
|
||
@attribute [Authorize]
|
||
@inject AuthorizedMembershipService MembershipService
|
||
@inject NavigationManager Navigation
|
||
|
||
<PageTitle>Мои клубы — GM-Relay</PageTitle>
|
||
|
||
<div class="page-container">
|
||
<ul class="gm-breadcrumb animate-fade-in">
|
||
<li><a href="/">Главная</a></li>
|
||
<li class="active">Мои клубы</li>
|
||
</ul>
|
||
|
||
<div class="page-header animate-fade-in">
|
||
<h2>🏛 Мои клубы</h2>
|
||
<p>Заявки и активные участия в приватных клубных витринах.</p>
|
||
</div>
|
||
|
||
@if (memberships is null)
|
||
{
|
||
<div class="glass-card" style="padding: 2rem;">
|
||
<div class="skeleton skeleton-text" style="width: 60%; height: 2rem; margin-bottom: 1rem;"></div>
|
||
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
||
</div>
|
||
}
|
||
else if (memberships.Count == 0)
|
||
{
|
||
<div class="glass-card public-empty-state">
|
||
<h2>Вы пока не подавали заявок</h2>
|
||
<p>Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.</p>
|
||
<a class="btn-gm btn-gm-primary" href="/showcase">К каталогу клубов</a>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@if (activeMemberships.Count > 0)
|
||
{
|
||
<section class="glass-card animate-slide-up">
|
||
<h3>Активные участия</h3>
|
||
<ul class="membership-list">
|
||
@foreach (var membership in activeMemberships)
|
||
{
|
||
<li>
|
||
<div class="membership-info">
|
||
<a href="@($"/club/{membership.GroupSlug ?? membership.GroupId.ToString()}")" class="membership-name">
|
||
@membership.GroupName
|
||
</a>
|
||
<span class="status-badge status-success">Участник</span>
|
||
</div>
|
||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||
Покинуть клуб
|
||
</button>
|
||
</li>
|
||
}
|
||
</ul>
|
||
</section>
|
||
}
|
||
|
||
@if (pendingMemberships.Count > 0)
|
||
{
|
||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||
<h3>Заявки на рассмотрении</h3>
|
||
<ul class="membership-list">
|
||
@foreach (var membership in pendingMemberships)
|
||
{
|
||
<li>
|
||
<div class="membership-info">
|
||
<span class="membership-name">@membership.GroupName</span>
|
||
<span class="status-badge status-warning">Ожидает одобрения</span>
|
||
</div>
|
||
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
|
||
Отозвать заявку
|
||
</button>
|
||
</li>
|
||
}
|
||
</ul>
|
||
</section>
|
||
}
|
||
|
||
@if (historyMemberships.Count > 0)
|
||
{
|
||
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
|
||
<h3>История</h3>
|
||
<ul class="membership-list">
|
||
@foreach (var membership in historyMemberships)
|
||
{
|
||
<li>
|
||
<div class="membership-info">
|
||
<span class="membership-name">@membership.GroupName</span>
|
||
<span class="status-badge @(membership.Status == "Rejected" ? "status-danger" : "status-neutral")">
|
||
@(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб")
|
||
</span>
|
||
@if (membership.DecidedAt is not null)
|
||
{
|
||
<span class="membership-meta">@membership.DecidedAt.Value.ToString("dd.MM.yyyy")</span>
|
||
}
|
||
</div>
|
||
</li>
|
||
}
|
||
</ul>
|
||
</section>
|
||
}
|
||
}
|
||
|
||
@if (!string.IsNullOrEmpty(errorMessage))
|
||
{
|
||
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
|
||
}
|
||
</div>
|
||
|
||
@code {
|
||
private List<WebMembership>? memberships;
|
||
private List<WebMembership> activeMemberships = [];
|
||
private List<WebMembership> pendingMemberships = [];
|
||
private List<WebMembership> historyMemberships = [];
|
||
private string? errorMessage;
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
await LoadAsync();
|
||
}
|
||
|
||
private async Task LoadAsync()
|
||
{
|
||
errorMessage = null;
|
||
memberships = await MembershipService.GetMineAsync();
|
||
activeMemberships = memberships.Where(m => m.Status == "Active").ToList();
|
||
pendingMemberships = memberships.Where(m => m.Status == "Pending").ToList();
|
||
historyMemberships = memberships.Where(m => m.Status is "Rejected" or "Left").ToList();
|
||
}
|
||
|
||
private async Task Leave(Guid membershipId)
|
||
{
|
||
errorMessage = null;
|
||
try
|
||
{
|
||
await MembershipService.LeaveClubForCurrentUserAsync(membershipId);
|
||
await LoadAsync();
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
errorMessage = ex.Message;
|
||
}
|
||
}
|
||
}
|