feat(ui): implement client-side [PAYWALL_TRIGGER] token parser, styling and tests

This commit is contained in:
2026-06-06 11:07:21 +02:00
parent 93133a49b6
commit e9bb51af77
4 changed files with 232 additions and 48 deletions
@@ -8,7 +8,7 @@
@inject NavigationManager NavigationManager
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
<div class="message-avatar">
<div class="message-avatar" aria-hidden="true">
@if (Message.Sender == "User")
{
<i class="bi bi-person-fill"></i>
@@ -32,10 +32,10 @@
}
else
{
@if (Message.IsPaywalled && !_isUnlocked)
@if (_hasPaywall)
{
<div class="clear-teaser">
@foreach (var segment in ParseSegments(Message.ClearText))
<div class="paywall-teaser" aria-hidden="true">
@foreach (var segment in ParseSegments(_displayTeaserText))
{
@if (segment.IsCitation)
{
@@ -48,47 +48,31 @@
}
</div>
<div class="teaser-blur">
@foreach (var segment in ParseSegments(Message.BlurredTeaserText))
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
<div class="upsell-card">
<div class="upsell-card" role="alert" aria-live="polite">
<div class="upsell-header">
<span class="upsell-icon">🔒</span>
<h4>Zablokowano pełną odpowiedź (Paywall)</h4>
<span class="upsell-icon" aria-hidden="true">🔒</span>
<h4>Dostęp Premium Zablokowany</h4>
</div>
<p class="upsell-text">
Powyższy fragment wiedzy pochodzi z materiału:
<strong>'@(string.IsNullOrEmpty(Message.SourceBookTitle) ? "Architektura .NET 10 i Ekosystem Blazor" : Message.SourceBookTitle)'</strong>.
Ta pozycja nie znajduje się w Twojej bibliotece (/my-books). Aby uzyskać dostęp do pełnych instrukcji technicznych oraz gotowych kodów C#, odblokuj ten materiał w katalogu.
Twoje zasoby odpowiadają na to pytanie w <strong>@_localScore%</strong>. W materiale <strong>'@_lockedBookTitle'</strong> znaleźliśmy odpowiedź dopasowaną w <strong>@_globalScore%</strong>.
</p>
<div class="upsell-actions">
@if (_isSimulatingPayment)
{
<button class="btn-upsell btn-primary loading" disabled>
<div class="payment-spinner"></div>
<button class="btn-upsell btn-primary loading" disabled aria-busy="true">
<div class="payment-spinner" aria-hidden="true"></div>
PRZETWARZANIE PŁATNOŚCI...
</button>
}
else
{
<button class="btn-upsell btn-primary" @onclick="HandlePurchase">
KUP PUBLIKACJĘ (29 PLN)
ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
</button>
}
<a href="/catalog" target="_blank" class="btn-upsell btn-secondary">
<a href="/catalog?bookId=@_lockedBookId" class="btn-upsell btn-secondary">
Zobacz szczegóły w Katalogu
</a>
</div>
@@ -97,7 +81,7 @@
else
{
<div class="full-response">
@foreach (var segment in Message.Segments)
@foreach (var segment in ParseSegments(GetCleanText()))
{
@if (segment.IsCitation)
{
@@ -112,8 +96,8 @@
@if (_showSuccessBanner)
{
<div class="success-unlock-banner">
<span class="success-icon">✓</span>
<div class="success-unlock-banner" role="status">
<span class="success-icon" aria-hidden="true">✓</span>
<span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span>
</div>
}
@@ -126,17 +110,61 @@
@code {
[Parameter] public ChatMessage Message { get; set; } = default!;
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
[Parameter] public EventCallback<Guid> OnUnlockRequested { get; set; }
private string GetBubbleClass()
{
if (Message.Sender == "User") return "user-bubble";
return Message.IsPaywalled && !_isUnlocked ? "ai-bubble paywalled-bubble" : "ai-bubble";
}
private bool _hasPaywall;
private string _displayTeaserText = string.Empty;
private Guid _lockedBookId;
private string _lockedBookTitle = string.Empty;
private int _localScore;
private int _globalScore;
private bool _isUnlocked = false;
private bool _isSimulatingPayment = false;
private bool _showSuccessBanner = false;
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Message != null && Message.Sender != "User" && !_isUnlocked)
{
_hasPaywall = PaywallParser.TryParsePaywallTrigger(Message.Text, out _displayTeaserText, out _lockedBookId, out _lockedBookTitle, out _localScore, out _globalScore);
// Additional check: if user already owns the book, don't show the paywall
if (_hasPaywall && OwnedBooks != null)
{
var isOwned = OwnedBooks.Any(b =>
b.Id == _lockedBookId ||
(!string.IsNullOrEmpty(b.Title) && b.Title.Equals(_lockedBookTitle, StringComparison.OrdinalIgnoreCase)));
if (isOwned)
{
_hasPaywall = false;
}
}
}
else
{
_hasPaywall = false;
}
}
private string GetCleanText()
{
if (Message == null) return string.Empty;
if (PaywallParser.TryParsePaywallTrigger(Message.Text, out var cleanText, out _, out _, out _, out _))
{
return cleanText;
}
return Message.Text;
}
private string GetBubbleClass()
{
if (Message.Sender == "User") return "user-bubble";
return _hasPaywall ? "ai-bubble paywalled-bubble" : "ai-bubble";
}
private async Task HandlePurchase()
{
if (_isSimulatingPayment) return;
@@ -149,21 +177,26 @@
try
{
var bookTitle = string.IsNullOrEmpty(Message.SourceBookTitle)
var bookTitle = string.IsNullOrEmpty(_lockedBookTitle)
? "Architektura .NET 10 i Ekosystem Blazor"
: Message.SourceBookTitle;
: _lockedBookTitle;
// Call POST endpoint to persist the purchase
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
if (response.IsSuccessStatusCode)
{
_isUnlocked = true;
Message.IsPaywalled = false;
_hasPaywall = false;
_showSuccessBanner = true;
// Fetch updated library list and update state manager
var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
LibraryStateService.OwnedBooks = updatedBooks;
if (OnUnlockRequested.HasDelegate)
{
await OnUnlockRequested.InvokeAsync(_lockedBookId);
}
}
else
{