feat(recommendations): implement contextual recommendation engine (#76)

Resolves #75

### Description
This pull request implements a smart, Native AOT-compliant contextual recommendation engine for the desktop dashboard to drive user retention and cross-book monetization.

### Key Changes
1. **Application Layer**:
   - Declared `IUserReadingStateStore` interface to decouple reading state discovery.
   - Added `IVectorSearchStore.SearchGlobalExcludeAsync(...)` to abstract semantic similarity searches with exclusions.
   - Defined `GetContextualRecommendationsQuery` and response DTOs (`ContextualRecommendationResponse`, `RecommendationDto`).
2. **Infrastructure Layer**:
   - Implemented `UserReadingStateStore` using EF Core with DbContext pooling.
   - Implemented `SearchGlobalExcludeAsync` in `VectorSearchStore` to construct gRPC Qdrant filters (excluding the active book ID) and fetch `bookTitle` and `chapterTitle` from point payloads.
   - Implemented `GetContextualRecommendationsQueryHandler` using clean abstractions.
3. **Web & Serialization Layer**:
   - Mapped `GET /api/recommendations` endpoint.
   - Registered types in `AppJsonContext.cs` for AOT-compliant JSON serialization.
4. **Verification**:
   - Added complete unit test coverage in `GetContextualRecommendationsQueryTests.cs`. All 30 unit tests pass.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #76
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #76.
This commit is contained in:
2026-06-06 13:38:48 +00:00
committed by Marek Jaisński
parent bcd5daa3a0
commit 1d6862016d
42 changed files with 2737 additions and 337 deletions
@@ -1,7 +1,9 @@
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.DTOs.AI
@using Microsoft.Extensions.Logging
@inject IQuizStateService QuizState
@inject KnowledgeCoordinator Coordinator
@inject ILogger<AiAssistantBubble> Logger
@implements IDisposable
<div class="ai-bubble-container">
@@ -134,7 +136,7 @@
catch (Exception ex)
{
_displayedText = string.IsNullOrEmpty(Dialogue) ? "Błąd analizy." : Dialogue;
Console.WriteLine($"[AiAssistantBubble] Error fetching summary: {ex.Message}");
Logger.LogError(ex, "[AiAssistantBubble] Error fetching summary for block {BlockId}.", ContextBlockId);
}
finally
{
@@ -0,0 +1,279 @@
@using NexusReader.UI.Shared.Models
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.DTOs.User
@using Microsoft.Extensions.Logging
@using System.Net.Http.Json
@inject HttpClient Http
@inject ILibraryStateService LibraryStateService
@inject NavigationManager NavigationManager
@inject ILogger<AiResponseRenderer> Logger
<div class="message-row @(Message.Sender == "User" ? "user-row" : "ai-row")">
<div class="message-avatar" aria-hidden="true">
@if (Message.Sender == "User")
{
<i class="bi bi-person-fill"></i>
}
else
{
<i class="bi bi-robot"></i>
}
</div>
<div class="message-bubble @GetBubbleClass()">
<div class="message-header">
<span class="sender-name">@Message.Sender</span>
<span class="message-time">@Message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-content">
@if (Message.Sender == "User")
{
<p>@Message.Text</p>
}
else
{
@if (_hasPaywall)
{
<div class="paywall-teaser" aria-hidden="true">
@foreach (var segment in ParseSegments(_displayTeaserText))
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
<div class="upsell-card" role="alert" aria-live="polite">
<div class="upsell-header">
<span class="upsell-icon" aria-hidden="true">🔒</span>
<h4>Dostęp Premium Zablokowany</h4>
</div>
<p class="upsell-text">
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 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">
ODBLOKUJ PEŁNĄ TREŚĆ (29 PLN)
</button>
}
<a href="/catalog?bookId=@_lockedBookId" class="btn-upsell btn-secondary">
Zobacz szczegóły w Katalogu
</a>
</div>
</div>
}
else
{
<div class="full-response">
@foreach (var segment in ParseSegments(GetCleanText()))
{
@if (segment.IsCitation)
{
<NexusCitationMarker SourceId="@segment.CitationId" Citations="@Message.Citations" />
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
@if (_showSuccessBanner)
{
<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>
}
}
}
</div>
</div>
</div>
@code {
[Parameter] public ChatMessage Message { get; set; } = default!;
[Parameter] public List<LastReadBookDto>? OwnedBooks { get; set; }
[Parameter] public EventCallback<Guid> OnUnlockRequested { get; set; }
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;
_isSimulatingPayment = true;
StateHasChanged();
// Simulate payment gateway delay (1.5 seconds)
await Task.Delay(1500);
try
{
var bookTitle = string.IsNullOrEmpty(_lockedBookTitle)
? "Architektura .NET 10 i Ekosystem Blazor"
: _lockedBookTitle;
// Call POST endpoint to persist the purchase
var response = await Http.PostAsJsonAsync("api/library/purchase", new { Title = bookTitle });
if (response.IsSuccessStatusCode)
{
_isUnlocked = true;
_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
{
Logger.LogWarning("[AiResponseRenderer] Purchase failed on server for book {BookId}.", _lockedBookId);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "[AiResponseRenderer] Error processing purchase for book {BookId}.", _lockedBookId);
}
finally
{
_isSimulatingPayment = false;
StateHasChanged();
}
}
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
var regex = new System.Text.RegularExpressions.Regex(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var matches = regex.Matches(text);
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in matches)
{
if (match.Index > lastIndex)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex, match.Index - lastIndex),
IsCitation = false
});
}
var citationId = match.Groups[1].Success
? match.Groups[1].Value.Trim()
: match.Groups[2].Value.Trim();
segments.Add(new ResponseSegment
{
IsCitation = true,
CitationId = citationId
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex),
IsCitation = false
});
}
return segments;
}
private MarkupString RenderMarkdown(string text)
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
var html = System.Net.WebUtility.HtmlEncode(text);
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-code-block\"><code>$1</code></pre>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "<code class=\"nexus-inline-code\">$1</code>");
html = html.Replace("\n", "<br />");
return new MarkupString(html);
}
}
@@ -0,0 +1,267 @@
.message-row {
display: flex;
gap: 1rem;
width: 100%;
max-width: 90%;
margin-bottom: 1.5rem;
animation: bubble-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.user-row {
align-self: flex-end;
margin-left: auto;
flex-direction: row-reverse;
}
.ai-row {
align-self: flex-start;
margin-right: auto;
}
.message-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.user-row .message-avatar {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
}
.ai-row .message-avatar {
background: linear-gradient(135deg, #005f38 0%, #004024 100%);
color: #e6fffa;
border: 1px solid rgba(0, 255, 153, 0.4);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.25);
}
.message-bubble {
padding: 1.25rem 1.5rem;
border-radius: 16px;
position: relative;
line-height: 1.6;
font-size: 0.975rem;
display: flex;
flex-direction: column;
width: 100%;
}
.user-bubble {
background: #1a1a1e;
border: 1px solid rgba(255, 255, 255, 0.05);
color: #e4e4e7;
border-top-right-radius: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.ai-bubble {
background: rgba(26, 26, 30, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
color: #e2e8f0;
border-top-left-radius: 4px;
box-shadow: 0 4px 25px rgba(0, 0, 0, 0.2);
}
.paywalled-bubble {
border-color: rgba(16, 185, 129, 0.15);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.75rem;
opacity: 0.6;
}
.sender-name {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.message-time {
font-family: monospace;
}
.message-content {
word-break: break-word;
}
/* Paragraph spacing */
.message-content p {
margin: 0 0 1rem 0;
}
.message-content p:last-child {
margin-bottom: 0;
}
/* Paywall Blur Styles */
.paywall-teaser {
position: relative;
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;
}
/* Upsell Card */
.upsell-card {
background: #1a1a1e;
border-radius: 12px;
border: 1px solid rgba(16, 185, 129, 0.25);
padding: 1.5rem;
margin-top: 1rem;
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;
}
.upsell-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.upsell-icon {
font-size: 1.25rem;
}
.upsell-header h4 {
margin: 0;
color: #10b981;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.5px;
}
.upsell-text {
color: rgba(255, 255, 255, 0.75);
font-size: 0.9rem;
line-height: 1.55;
margin: 0 0 1.25rem 0;
}
.upsell-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.btn-upsell {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
border-radius: 8px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
letter-spacing: 0.5px;
min-height: 44px;
}
.btn-primary {
background: #10b981;
border: none;
color: #121214;
}
.btn-primary:hover:not(:disabled) {
background: #0d9668;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-primary:disabled {
background: rgba(16, 185, 129, 0.5);
color: rgba(18, 18, 20, 0.6);
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
border: 1px solid #10b981;
color: #10b981;
}
.btn-secondary:hover {
background: rgba(16, 185, 129, 0.05);
transform: translateY(-2px);
}
.btn-secondary:active {
transform: translateY(0);
}
/* Success Banner */
.success-unlock-banner {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #10b981;
padding: 1rem;
border-radius: 8px;
margin-top: 1.25rem;
font-size: 0.9rem;
font-weight: 600;
animation: fade-in 0.5s ease-out;
}
.success-icon {
font-weight: bold;
font-size: 1.1rem;
}
/* Payment Spinner */
.payment-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(18, 18, 20, 0.2);
border-top-color: #121214;
border-radius: 50%;
margin-right: 0.75rem;
animation: spin 0.8s linear infinite;
}
/* Keyframes */
@keyframes bubble-fade-in {
0% { opacity: 0; transform: translateY(12px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes card-slide-in {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@@ -1,10 +1,13 @@
@using NexusReader.UI.Shared.Services
@using NexusReader.Application.Abstractions.Services
@using Microsoft.Extensions.Logging
@using System.Linq
@inject IFocusModeService FocusMode
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
@inject IThemeService ThemeService
@inject IKnowledgeService KnowledgeService
@inject ILogger<IntelligenceToolbar> Logger
@implements IDisposable
<aside class="intelligence-toolbar">
@@ -51,11 +54,15 @@
private async Task HandleClearCache()
{
Console.WriteLine("[IntelligenceToolbar] Requesting cache clear...");
Logger.LogInformation("[IntelligenceToolbar] Requesting cache clear...");
var result = await KnowledgeService.ClearCacheAsync();
if (result.IsSuccess)
{
Console.WriteLine("[IntelligenceToolbar] Cache cleared successfully!");
Logger.LogInformation("[IntelligenceToolbar] Cache cleared successfully.");
}
else
{
Logger.LogWarning("[IntelligenceToolbar] Cache clear failed: {Errors}", string.Join("; ", result.Errors.Select(e => e.Message)));
}
}
@@ -1,10 +1,12 @@
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using NexusReader.Application.DTOs.AI
@using Microsoft.Extensions.Logging
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
@inject IQuizStateService QuizService
@inject IJSRuntime JS
@inject ILogger<SelectionAiPanel> Logger
@if (IsVisible)
{
@@ -64,7 +66,7 @@
protected override void OnParametersSet()
{
Console.WriteLine($"[SelectionAiPanel] Parameters set. SelectedText: {SelectedText.Length} chars, Coordinates: {Coordinates?.Top}");
Logger.LogDebug("[SelectionAiPanel] Parameters set. SelectedText: {Length} chars, Coordinates: {Top}", SelectedText.Length, Coordinates?.Top);
if (Coordinates != _lastCoordinates)
{
@@ -100,7 +102,7 @@
}
catch (Exception ex)
{
Console.WriteLine($"[SelectionAiPanel] Error positioning toolbar: {ex.Message}");
Logger.LogWarning(ex, "[SelectionAiPanel] Error positioning toolbar.");
}
}
}
@@ -133,7 +135,7 @@
}
catch (Exception ex)
{
Console.WriteLine($"[SelectionAiPanel] Error requesting summary: {ex.Message}");
Logger.LogError(ex, "[SelectionAiPanel] Error requesting summary for block {BlockId}.", BlockId);
}
finally
{
@@ -173,7 +175,7 @@
}
catch (Exception ex)
{
Console.WriteLine($"[SelectionAiPanel] Error generating quiz: {ex.Message}");
Logger.LogError(ex, "[SelectionAiPanel] Error generating quiz for block {BlockId}.", BlockId);
}
finally
{
@@ -0,0 +1,112 @@
@using NexusReader.UI.Shared.Components.Atoms
@using Microsoft.Extensions.Logging
@inject IRecommendationService RecommendationService
@inject NavigationManager NavigationManager
@inject ILogger<ContextualRecommendationsWidget> Logger
<section class="recommendations-panel glass-panel" aria-label="Kontekstowe rekomendacje">
<div class="panel-header">
<div class="header-left">
<NexusIcon Name="sparkles" Size="18" />
<h4>Odkryj Więcej</h4>
</div>
<span class="panel-badge">AI</span>
</div>
@if (_isLoading)
{
<div class="loading-state" role="status" aria-label="Ładowanie rekomendacji">
<div class="spinner-ring">
<div class="spinner-track"></div>
<div class="spinner-head"></div>
</div>
<span class="loading-label">Analizowanie kontekstu lektury…</span>
</div>
}
else if (_hasError)
{
<div class="empty-state">
<NexusIcon Name="alert-circle" Size="32" />
<p>Nie udało się załadować rekomendacji.</p>
</div>
}
else if (_recommendations is null || _recommendations.Count == 0)
{
<div class="empty-state">
<NexusIcon Name="book-open" Size="32" />
<p>Zacznij czytać, aby odkryć powiązane tytuły.</p>
</div>
}
else
{
<ul class="recommendations-list" role="list">
@foreach (var rec in _recommendations)
{
<li class="recommendation-item @(rec.IsPremiumUpsell ? "premium" : "owned")"
role="listitem">
<div class="rec-content">
<div class="rec-meta">
<span class="match-badge" title="Dopasowanie semantyczne @rec.MatchPercentage%">
@rec.MatchPercentage<span class="match-unit">%</span>
</span>
@if (rec.IsPremiumUpsell)
{
<span class="upsell-tag" aria-label="Książka premium">Premium</span>
}
</div>
<p class="rec-book-title">@rec.BookTitle</p>
<p class="rec-chapter-title">@rec.ChapterTitle</p>
</div>
<button class="rec-action-btn"
@onclick="() => HandleRecommendationClick(rec)"
aria-label="@(rec.IsPremiumUpsell ? "Kup " + rec.BookTitle : "Przejdź do " + rec.BookTitle)">
<NexusIcon Name="@(rec.IsPremiumUpsell ? "shopping-cart" : "arrow-right")" Size="16" />
</button>
</li>
}
</ul>
}
</section>
@code {
private List<RecommendationDto>? _recommendations;
private bool _isLoading = true;
private bool _hasError;
protected override async Task OnInitializedAsync()
{
await LoadRecommendationsAsync();
}
private async Task LoadRecommendationsAsync()
{
_isLoading = true;
_hasError = false;
try
{
_recommendations = await RecommendationService.GetRecommendationsAsync();
if (_recommendations is null)
{
_hasError = true;
Logger.LogWarning("[ContextualRecommendationsWidget] RecommendationService returned null; displaying error state.");
}
}
finally
{
_isLoading = false;
}
}
private void HandleRecommendationClick(RecommendationDto rec)
{
if (rec.IsPremiumUpsell)
{
NavigationManager.NavigateTo($"/catalog?highlight={rec.TargetBookId}");
}
else
{
NavigationManager.NavigateTo($"/reader/{rec.TargetBookId}");
}
}
}
@@ -0,0 +1,253 @@
/* ContextualRecommendationsWidget.razor.css
Uses Nexus Design System tokens (--nexus-*) for consistency.
*/
.recommendations-panel {
width: 100%;
padding: 1.75rem;
background: var(--nexus-surface, #1a1a1e);
border: 1px solid var(--nexus-border, rgba(255, 255, 255, 0.05));
border-radius: 12px;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.recommendations-panel:hover {
border-color: rgba(16, 185, 129, 0.2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* ── Panel Header ── */
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--nexus-accent, #10b981);
}
.header-left h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--nexus-text-primary, #ffffff);
letter-spacing: 0.02em;
}
.panel-badge {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.2rem 0.55rem;
border-radius: 100px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(59, 130, 246, 0.1));
border: 1px solid rgba(16, 185, 129, 0.3);
color: var(--nexus-accent, #10b981);
text-transform: uppercase;
}
/* ── Loading State ── */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem 1rem;
}
.spinner-ring {
position: relative;
width: 40px;
height: 40px;
}
.spinner-track {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.05);
}
.spinner-head {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: var(--nexus-accent, #10b981);
animation: nexus-spin 0.8s linear infinite;
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
}
@keyframes nexus-spin {
to { transform: rotate(360deg); }
}
.loading-label {
font-size: 0.82rem;
color: var(--nexus-text-secondary, #a1a1aa);
font-style: italic;
}
/* ── Empty / Error State ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
text-align: center;
color: var(--nexus-text-secondary, #a1a1aa);
opacity: 0.65;
}
.empty-state p {
margin: 0;
font-size: 0.875rem;
}
/* ── Recommendations List ── */
.recommendations-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.recommendation-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.1rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: default;
}
.recommendation-item:hover {
background: rgba(255, 255, 255, 0.04);
transform: translateX(2px);
}
.recommendation-item.premium {
border-color: rgba(245, 158, 11, 0.2);
}
.recommendation-item.premium:hover {
border-color: rgba(245, 158, 11, 0.4);
background: rgba(245, 158, 11, 0.04);
}
.recommendation-item.owned {
border-color: rgba(16, 185, 129, 0.1);
}
.recommendation-item.owned:hover {
border-color: rgba(16, 185, 129, 0.25);
}
/* ── Rec Content ── */
.rec-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rec-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.15rem;
}
.match-badge {
font-size: 0.8rem;
font-weight: 700;
color: var(--nexus-accent, #10b981);
background: rgba(16, 185, 129, 0.1);
border-radius: 4px;
padding: 0.1rem 0.45rem;
}
.match-unit {
font-size: 0.65rem;
font-weight: 500;
}
.upsell-tag {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 4px;
padding: 0.1rem 0.45rem;
text-transform: uppercase;
}
.rec-book-title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--nexus-text-primary, #ffffff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rec-chapter-title {
margin: 0;
font-size: 0.78rem;
color: var(--nexus-text-secondary, #a1a1aa);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Action Button ── */
.rec-action-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
color: var(--nexus-text-secondary, #a1a1aa);
cursor: pointer;
transition: all 0.2s ease;
}
.rec-action-btn:hover {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.3);
color: var(--nexus-accent, #10b981);
transform: scale(1.1);
}
.premium .rec-action-btn:hover {
background: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
@media (max-width: 768px) {
.recommendations-panel {
padding: 1.25rem;
}
}