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 @inject NavigationManager NavigationManager
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")"> <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") @if (Message.Sender == "User")
{ {
<i class="bi bi-person-fill"></i> <i class="bi bi-person-fill"></i>
@@ -32,10 +32,10 @@
} }
else else
{ {
@if (Message.IsPaywalled && !_isUnlocked) @if (_hasPaywall)
{ {
<div class="clear-teaser"> <div class="paywall-teaser" aria-hidden="true">
@foreach (var segment in ParseSegments(Message.ClearText)) @foreach (var segment in ParseSegments(_displayTeaserText))
{ {
@if (segment.IsCitation) @if (segment.IsCitation)
{ {
@@ -48,47 +48,31 @@
} }
</div> </div>
<div class="teaser-blur"> <div class="upsell-card" role="alert" aria-live="polite">
@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-header"> <div class="upsell-header">
<span class="upsell-icon">🔒</span> <span class="upsell-icon" aria-hidden="true">🔒</span>
<h4>Zablokowano pełną odpowiedź (Paywall)</h4> <h4>Dostęp Premium Zablokowany</h4>
</div> </div>
<p class="upsell-text"> <p class="upsell-text">
Powyższy fragment wiedzy pochodzi z materiału: Twoje zasoby odpowiadają na to pytanie w <strong>@_localScore%</strong>. W materiale <strong>'@_lockedBookTitle'</strong> znaleźliśmy odpowiedź dopasowaną w <strong>@_globalScore%</strong>.
<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.
</p> </p>
<div class="upsell-actions"> <div class="upsell-actions">
@if (_isSimulatingPayment) @if (_isSimulatingPayment)
{ {
<button class="btn-upsell btn-primary loading" disabled> <button class="btn-upsell btn-primary loading" disabled aria-busy="true">
<div class="payment-spinner"></div> <div class="payment-spinner" aria-hidden="true"></div>
PRZETWARZANIE PŁATNOŚCI... PRZETWARZANIE PŁATNOŚCI...
</button> </button>
} }
else else
{ {
<button class="btn-upsell btn-primary" @onclick="HandlePurchase"> <button class="btn-upsell btn-primary" @onclick="HandlePurchase">
KUP PUBLIKACJĘ (29 PLN) ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
</button> </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 Zobacz szczegóły w Katalogu
</a> </a>
</div> </div>
@@ -97,7 +81,7 @@
else else
{ {
<div class="full-response"> <div class="full-response">
@foreach (var segment in Message.Segments) @foreach (var segment in ParseSegments(GetCleanText()))
{ {
@if (segment.IsCitation) @if (segment.IsCitation)
{ {
@@ -112,8 +96,8 @@
@if (_showSuccessBanner) @if (_showSuccessBanner)
{ {
<div class="success-unlock-banner"> <div class="success-unlock-banner" role="status">
<span class="success-icon">✓</span> <span class="success-icon" aria-hidden="true">✓</span>
<span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span> <span>Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.</span>
</div> </div>
} }
@@ -126,17 +110,61 @@
@code { @code {
[Parameter] public ChatMessage Message { get; set; } = default!; [Parameter] public ChatMessage Message { get; set; } = default!;
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; } [Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
[Parameter] public EventCallback<Guid> OnUnlockRequested { get; set; }
private string GetBubbleClass() private bool _hasPaywall;
{ private string _displayTeaserText = string.Empty;
if (Message.Sender == "User") return "user-bubble"; private Guid _lockedBookId;
return Message.IsPaywalled && !_isUnlocked ? "ai-bubble paywalled-bubble" : "ai-bubble"; private string _lockedBookTitle = string.Empty;
} private int _localScore;
private int _globalScore;
private bool _isUnlocked = false; private bool _isUnlocked = false;
private bool _isSimulatingPayment = false; private bool _isSimulatingPayment = false;
private bool _showSuccessBanner = 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() private async Task HandlePurchase()
{ {
if (_isSimulatingPayment) return; if (_isSimulatingPayment) return;
@@ -149,21 +177,26 @@
try try
{ {
var bookTitle = string.IsNullOrEmpty(Message.SourceBookTitle) var bookTitle = string.IsNullOrEmpty(_lockedBookTitle)
? "Architektura .NET 10 i Ekosystem Blazor" ? "Architektura .NET 10 i Ekosystem Blazor"
: Message.SourceBookTitle; : _lockedBookTitle;
// Call POST endpoint to persist the purchase // Call POST endpoint to persist the purchase
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle }); var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
_isUnlocked = true; _isUnlocked = true;
Message.IsPaywalled = false; _hasPaywall = false;
_showSuccessBanner = true; _showSuccessBanner = true;
// Fetch updated library list and update state manager // Fetch updated library list and update state manager
var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books"); var updatedBooks = await Http.GetFromJsonAsync<List<LastReadBookDto>>("api/library/books");
LibraryStateService.OwnedBooks = updatedBooks; LibraryStateService.OwnedBooks = updatedBooks;
if (OnUnlockRequested.HasDelegate)
{
await OnUnlockRequested.InvokeAsync(_lockedBookId);
}
} }
else else
{ {
@@ -106,27 +106,25 @@
} }
/* Paywall Blur Styles */ /* Paywall Blur Styles */
.teaser-blur { .paywall-teaser {
position: relative; position: relative;
filter: blur(5px); margin-bottom: 1.5rem;
-webkit-mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
mask-image: linear-gradient(to bottom, black 30%, transparent 100%);
filter: blur(2px);
pointer-events: none; pointer-events: none;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
opacity: 0.35;
margin-top: 1rem;
margin-bottom: 1.5rem;
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 100%);
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 40%, rgba(0,0,0,0) 100%);
} }
/* Upsell Card */ /* Upsell Card */
.upsell-card { .upsell-card {
background: #1a1a1e; background: #1a1a1e;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(16, 185, 129, 0.2); border: 1px solid rgba(16, 185, 129, 0.25);
padding: 1.5rem; padding: 1.5rem;
margin-top: 1rem; margin-top: 1rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08), 0 4px 12px rgba(0, 0, 0, 0.4);
animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: card-slide-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
} }
@@ -0,0 +1,72 @@
using System;
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// AOT-safe string parsing utility to isolate paywall teaser details without regex overhead.
/// </summary>
public static class PaywallParser
{
public static bool TryParsePaywallTrigger(
string rawText,
out string displayTeaserText,
out Guid lockedBookId,
out string lockedBookTitle,
out int localScore,
out int globalScore)
{
displayTeaserText = rawText;
lockedBookId = Guid.Empty;
lockedBookTitle = string.Empty;
localScore = 0;
globalScore = 0;
if (string.IsNullOrEmpty(rawText))
return false;
ReadOnlySpan<char> span = rawText.AsSpan();
int tokenStartIndex = span.IndexOf("[PAYWALL_TRIGGER:");
if (tokenStartIndex == -1)
return false;
displayTeaserText = span.Slice(0, tokenStartIndex).Trim().ToString();
ReadOnlySpan<char> tokenContent = span.Slice(tokenStartIndex + "[PAYWALL_TRIGGER:".Length);
int tokenEndIndex = tokenContent.IndexOf(']');
if (tokenEndIndex == -1)
return false;
tokenContent = tokenContent.Slice(0, tokenEndIndex);
int firstColonIdx = tokenContent.IndexOf(':');
if (firstColonIdx == -1)
return false;
ReadOnlySpan<char> guidSpan = tokenContent.Slice(0, firstColonIdx);
if (!Guid.TryParse(guidSpan, out lockedBookId))
return false;
ReadOnlySpan<char> remaining = tokenContent.Slice(firstColonIdx + 1);
int lastColonIdx = remaining.LastIndexOf(':');
if (lastColonIdx == -1)
return false;
ReadOnlySpan<char> globalScoreSpan = remaining.Slice(lastColonIdx + 1);
if (!int.TryParse(globalScoreSpan, out globalScore))
return false;
remaining = remaining.Slice(0, lastColonIdx);
int secondLastColonIdx = remaining.LastIndexOf(':');
if (secondLastColonIdx == -1)
return false;
ReadOnlySpan<char> localScoreSpan = remaining.Slice(secondLastColonIdx + 1);
if (!int.TryParse(localScoreSpan, out localScore))
return false;
lockedBookTitle = remaining.Slice(0, secondLastColonIdx).ToString();
return true;
}
}
@@ -0,0 +1,81 @@
using System;
using FluentAssertions;
using NexusReader.UI.Shared.Services;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class PaywallParserTests
{
[Fact]
public void TryParsePaywallTrigger_WithValidSimpleToken_ReturnsTrueAndCorrectValues()
{
// Arrange
var guid = Guid.NewGuid();
var rawText = $"Teaser sentence. [PAYWALL_TRIGGER:{guid}:Clean Book Title:45:82]";
// Act
var result = PaywallParser.TryParsePaywallTrigger(
rawText,
out var teaser,
out var bookId,
out var title,
out var localScore,
out var globalScore);
// Assert
result.Should().BeTrue();
teaser.Should().Be("Teaser sentence.");
bookId.Should().Be(guid);
title.Should().Be("Clean Book Title");
localScore.Should().Be(45);
globalScore.Should().Be(82);
}
[Fact]
public void TryParsePaywallTrigger_WithColonsInBookTitle_ReturnsTrueAndCorrectValues()
{
// Arrange
var guid = Guid.NewGuid();
var rawText = $"Teaser text. [PAYWALL_TRIGGER:{guid}:Architektura: .NET 10 i C# 14:15:99]";
// Act
var result = PaywallParser.TryParsePaywallTrigger(
rawText,
out var teaser,
out var bookId,
out var title,
out var localScore,
out var globalScore);
// Assert
result.Should().BeTrue();
teaser.Should().Be("Teaser text.");
bookId.Should().Be(guid);
title.Should().Be("Architektura: .NET 10 i C# 14");
localScore.Should().Be(15);
globalScore.Should().Be(99);
}
[Theory]
[InlineData("")]
[InlineData("Just plain text with no trigger token.")]
[InlineData("Plain text [PAYWALL_TRIGGER:invalid-guid:Title:50:80]")]
[InlineData("Plain text [PAYWALL_TRIGGER:00000000-0000-0000-0000-000000000000:Title:50:invalid]")]
[InlineData("Plain text [PAYWALL_TRIGGER:00000000-0000-0000-0000-000000000000:Title:invalid:80]")]
[InlineData("Plain text [PAYWALL_TRIGGER:00000000-0000-0000-0000-000000000000:Title]")]
public void TryParsePaywallTrigger_WithInvalidInputs_ReturnsFalse(string rawText)
{
// Act
var result = PaywallParser.TryParsePaywallTrigger(
rawText,
out var teaser,
out var bookId,
out var title,
out var localScore,
out var globalScore);
// Assert
result.Should().BeFalse();
}
}