22e9859fdf
PR Checks / test-and-build (pull_request) Successful in 7m50s
Address review feedback from PR #119: - LeaveClubMembershipAsync: was rejecting Pending rows because the SQL required status = 'Active', so clicking "Отозвать заявку" on a Pending membership surfaced a misleading "Active membership X not found" InvalidOperationException. Now the method first tries Active -> Left and falls back to Pending -> Rejected so the same UI flow covers both states. - PublicClub.razor TrySubmitApplicationAsync: removed the empty-input guard that contradicted the "(необязательно)" label and the server side (AuthorizedMembershipService already trims and accepts null). No tests broken (493 still passing), no public-API changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
266 lines
11 KiB
Plaintext
266 lines
11 KiB
Plaintext
@page "/club/{Slug}"
|
|
@layout PublicLayout
|
|
@inject ISessionStore SessionStore
|
|
@inject IPortfolioStore PortfolioStore
|
|
@inject NavigationManager Navigation
|
|
@inject IHttpContextAccessor HttpContextAccessor
|
|
@inject AuthorizedMembershipService MembershipService
|
|
@using System.Security.Claims
|
|
@using GmRelay.Web.Components.Portfolio
|
|
@using GmRelay.Web.Services.Portfolio
|
|
|
|
<PageTitle>@PageTitleText</PageTitle>
|
|
|
|
@if (loaded && club 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 (club is not null)
|
|
{
|
|
<HeadContent>
|
|
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
|
|
</HeadContent>
|
|
|
|
<section class="public-hero">
|
|
<span class="status-badge status-success">Публичное расписание</span>
|
|
<h1>@club.Name</h1>
|
|
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
|
|
<div class="public-share-row">
|
|
<span>Ссылка клуба</span>
|
|
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
|
|
</div>
|
|
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
|
|
{
|
|
<div class="public-share-row">
|
|
<span>Мастер</span>
|
|
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
|
|
@(club.MasterDisplayName ?? "Профиль мастера")
|
|
</a>
|
|
</div>
|
|
}
|
|
</section>
|
|
|
|
@if (club.Sessions.Count == 0)
|
|
{
|
|
<div class="glass-card public-empty-state">
|
|
<h2>Опубликованных игр пока нет</h2>
|
|
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
|
|
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
|
|
|
|
@if (publicSessions.Count > 0)
|
|
{
|
|
<div class="public-session-list">
|
|
@foreach (var session in publicSessions)
|
|
{
|
|
<article class="public-session-card">
|
|
<div class="public-session-main">
|
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
|
<h2>@session.Title</h2>
|
|
<div class="public-session-meta">
|
|
<span>@session.ScheduledAt.FormatMoscow()</span>
|
|
<span>@FormatSeats(session)</span>
|
|
</div>
|
|
</div>
|
|
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
|
</article>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (membersOnlySessions.Count > 0)
|
|
{
|
|
<section class="glass-card members-only-section">
|
|
<h2>Игры для участников клуба</h2>
|
|
@if (viewerIsActiveMember)
|
|
{
|
|
<div class="public-session-list">
|
|
@foreach (var session in membersOnlySessions)
|
|
{
|
|
<article class="public-session-card">
|
|
<div class="public-session-main">
|
|
<span class="status-badge status-warning">Только для участников</span>
|
|
<h2>@session.Title</h2>
|
|
<div class="public-session-meta">
|
|
<span>@session.ScheduledAt.FormatMoscow()</span>
|
|
<span>@FormatSeats(session)</span>
|
|
</div>
|
|
</div>
|
|
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
|
|
</article>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p>Эти сессии доступны только одобренным участникам клуба.</p>
|
|
@if (viewerPlayerId is null)
|
|
{
|
|
<a class="btn-gm btn-gm-primary" href="/login?returnUrl=/club/@Slug">Войти как участник</a>
|
|
}
|
|
else
|
|
{
|
|
<details class="application-form">
|
|
<summary class="btn-gm btn-gm-primary">Подать заявку</summary>
|
|
<EditForm Model="@this" OnValidSubmit="TrySubmitApplicationAsync">
|
|
<div class="gm-form-group">
|
|
<label class="gm-form-label" for="applicationMessage">Сообщение мастеру (необязательно)</label>
|
|
<textarea id="applicationMessage" class="gm-form-control" maxlength="1000" @bind="applicationMessage" rows="3"></textarea>
|
|
</div>
|
|
@if (!string.IsNullOrEmpty(applicationError))
|
|
{
|
|
<p class="form-error">@applicationError</p>
|
|
}
|
|
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmittingApplication">Отправить</button>
|
|
</EditForm>
|
|
</details>
|
|
}
|
|
}
|
|
</section>
|
|
}
|
|
}
|
|
|
|
@if (portfolioGames.Count > 0)
|
|
{
|
|
<section class="glass-card portfolio-section">
|
|
<h2>Завершённые игры клуба</h2>
|
|
<p>Публичные портфолио, опубликованные мастерами этого клуба.</p>
|
|
<PortfolioCardGrid Games="portfolioGames" />
|
|
</section>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public string? Slug { get; set; }
|
|
|
|
private WebPublicClub? club;
|
|
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
|
|
private bool loaded;
|
|
private Guid? viewerPlayerId;
|
|
private bool viewerIsActiveMember;
|
|
private string? applicationError;
|
|
private string? applicationMessage;
|
|
private bool isSubmittingApplication;
|
|
|
|
private async Task TrySubmitApplicationAsync()
|
|
{
|
|
applicationError = null;
|
|
if (club is null)
|
|
return;
|
|
|
|
try
|
|
{
|
|
isSubmittingApplication = true;
|
|
await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage);
|
|
applicationMessage = null;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
applicationError = ex.Message;
|
|
}
|
|
finally
|
|
{
|
|
isSubmittingApplication = false;
|
|
}
|
|
}
|
|
|
|
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
|
|
|
|
private string PublicClubUrl =>
|
|
club is null
|
|
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
|
|
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
|
|
|
|
protected override async Task OnParametersSetAsync()
|
|
{
|
|
loaded = false;
|
|
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
|
|
applicationError = null;
|
|
applicationMessage = null;
|
|
|
|
// Resolve viewer identity (player id) for member-aware access.
|
|
var user = HttpContextAccessor.HttpContext?.User;
|
|
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
|
|
{
|
|
// We don't have platform here, but AuthorizedSessionService resolves via claims; use SessionStore directly
|
|
// by reading both claims. Simpler: only resolve when both Platform and externalUserId are present.
|
|
var platform = user.FindFirst("Platform")?.Value;
|
|
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
|
|
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
|
|
: null;
|
|
}
|
|
else
|
|
{
|
|
viewerPlayerId = null;
|
|
}
|
|
|
|
club = trimmedSlug is null
|
|
? null
|
|
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId);
|
|
portfolioGames = trimmedSlug is null
|
|
? []
|
|
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
|
|
|
|
if (club is not null && viewerPlayerId is not null)
|
|
{
|
|
viewerIsActiveMember = await SessionStore.IsActiveClubMemberAsync(club.GroupId, viewerPlayerId.Value);
|
|
}
|
|
else
|
|
{
|
|
viewerIsActiveMember = false;
|
|
}
|
|
|
|
loaded = true;
|
|
}
|
|
|
|
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
|
|
|
|
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
|
|
|
|
private static string FormatSeats(WebPublicSession session)
|
|
{
|
|
var seats = session.MaxPlayers.HasValue
|
|
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
|
: $"{session.ActivePlayerCount} игроков";
|
|
|
|
return session.WaitlistedPlayerCount > 0
|
|
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
|
|
: seats;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|