feat(ui): implement client-side [PAYWALL_TRIGGER] token parser, styling and tests
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -106,27 +106,25 @@
|
||||
}
|
||||
|
||||
/* Paywall Blur Styles */
|
||||
.teaser-blur {
|
||||
.paywall-teaser {
|
||||
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;
|
||||
-webkit-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 {
|
||||
background: #1a1a1e;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
padding: 1.5rem;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user