@if (Message.Sender == "User")
{
@@ -32,10 +32,10 @@
}
else
{
- @if (Message.IsPaywalled && !_isUnlocked)
+ @if (_hasPaywall)
{
-
- @foreach (var segment in ParseSegments(Message.ClearText))
+
+ @foreach (var segment in ParseSegments(_displayTeaserText))
{
@if (segment.IsCitation)
{
@@ -48,47 +48,31 @@
}
-
- @foreach (var segment in ParseSegments(Message.BlurredTeaserText))
- {
- @if (segment.IsCitation)
- {
-
- }
- else
- {
- @RenderMarkdown(segment.Text)
- }
- }
-
-
-
+
- Powyższy fragment wiedzy pochodzi z materiału:
- '@(string.IsNullOrEmpty(Message.SourceBookTitle) ? "Architektura .NET 10 i Ekosystem Blazor" : Message.SourceBookTitle)' .
- 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 @_localScore% . W materiale '@_lockedBookTitle' znaleźliśmy odpowiedź dopasowaną w @_globalScore% .
@if (_isSimulatingPayment)
{
-
-
+
+
PRZETWARZANIE PŁATNOŚCI...
}
else
{
- KUP PUBLIKACJĘ (29 PLN)
+ ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
}
-
+
Zobacz szczegóły w Katalogu
@@ -97,7 +81,7 @@
else
{
- @foreach (var segment in Message.Segments)
+ @foreach (var segment in ParseSegments(GetCleanText()))
{
@if (segment.IsCitation)
{
@@ -112,8 +96,8 @@
@if (_showSuccessBanner)
{
-
-
✓
+
+ ✓
Odblokowano pełną odpowiedź! Książka została dodana do Twojej biblioteki.
}
@@ -126,17 +110,61 @@
@code {
[Parameter] public ChatMessage Message { get; set; } = default!;
[Parameter] public List
? OwnedBooks { get; set; }
+ [Parameter] public EventCallback 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>("api/library/books");
LibraryStateService.OwnedBooks = updatedBooks;
+
+ if (OnUnlockRequested.HasDelegate)
+ {
+ await OnUnlockRequested.InvokeAsync(_lockedBookId);
+ }
}
else
{
diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css
index cc479c0..b8d96f1 100644
--- a/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css
+++ b/src/NexusReader.UI.Shared/Components/Molecules/AiResponseRenderer.razor.css
@@ -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;
}
diff --git a/src/NexusReader.UI.Shared/Services/PaywallParser.cs b/src/NexusReader.UI.Shared/Services/PaywallParser.cs
new file mode 100644
index 0000000..5e27f53
--- /dev/null
+++ b/src/NexusReader.UI.Shared/Services/PaywallParser.cs
@@ -0,0 +1,72 @@
+using System;
+
+namespace NexusReader.UI.Shared.Services;
+
+///
+/// AOT-safe string parsing utility to isolate paywall teaser details without regex overhead.
+///
+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 span = rawText.AsSpan();
+ int tokenStartIndex = span.IndexOf("[PAYWALL_TRIGGER:");
+ if (tokenStartIndex == -1)
+ return false;
+
+ displayTeaserText = span.Slice(0, tokenStartIndex).Trim().ToString();
+
+ ReadOnlySpan 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 guidSpan = tokenContent.Slice(0, firstColonIdx);
+ if (!Guid.TryParse(guidSpan, out lockedBookId))
+ return false;
+
+ ReadOnlySpan remaining = tokenContent.Slice(firstColonIdx + 1);
+
+ int lastColonIdx = remaining.LastIndexOf(':');
+ if (lastColonIdx == -1)
+ return false;
+
+ ReadOnlySpan 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 localScoreSpan = remaining.Slice(secondLastColonIdx + 1);
+ if (!int.TryParse(localScoreSpan, out localScore))
+ return false;
+
+ lockedBookTitle = remaining.Slice(0, secondLastColonIdx).ToString();
+ return true;
+ }
+}
diff --git a/tests/NexusReader.Application.Tests/Services/PaywallParserTests.cs b/tests/NexusReader.Application.Tests/Services/PaywallParserTests.cs
new file mode 100644
index 0000000..77cdd94
--- /dev/null
+++ b/tests/NexusReader.Application.Tests/Services/PaywallParserTests.cs
@@ -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();
+ }
+}