feat(recommendations): implement contextual recommendation engine #76
@@ -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>
|
||||||
|
Antigravity
commented
Verify color contrast for premium upsell badge meets WCAG AA; consider using CSS variables for theme colors. Verify color contrast for premium upsell badge meets WCAG AA; consider using CSS variables for theme colors.
|
|||||||
<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">
|
||||||
|
Antigravity
commented
Ensure component CSS uses custom properties to respect dark mode. Ensure component CSS uses custom properties to respect dark mode.
|
|||||||
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
Add tabindex="0" to the root div for keyboard focus and ensure focus is set after rendering.