Add UserActionLog and enhance registry features
Build and Push Docker Image / build-and-push (push) Successful in 1m59s

- Introduced `UserActionLog` entity to track user actions.
- Replaced `CanBeSecondHand` with `PreferSecondHand` property.
- Added `ShowBankAccountName` to `RegistrySettings`.
- Updated models and migrations for new properties.
- Enhanced `RegistryService` with user action logging and item details.
- Redesigned `Home.razor` with a grid layout and modal for registries.
- Added `RegistryActionLog.razor` for admin action logs.
- Improved `RegistryPublic.razor` with purchaser/contributor details.
- Replaced sidebar with `TopBar.razor` for responsive navigation.
- Updated CSS for new components and improved responsiveness.
This commit is contained in:
Arne Moerman
2026-05-17 20:57:54 +02:00
parent 6b593828d7
commit 2bf0295508
32 changed files with 3811 additions and 435 deletions
+156 -70
View File
@@ -1,86 +1,172 @@
@page "/"
@using BirthList.Web.Features.Registries
<PageTitle>Birth Registry</PageTitle>
<h1>Birth Registry</h1>
<h1>Welcome to Gift List</h1>
<AuthorizeView>
<Authorized Context="authState">
<div class="mb-4">
<h2>Create your registry</h2>
<EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form">
<DataAnnotationsValidator />
<div class="row g-3">
<div class="col-md-5">
<label class="form-label">Title</label>
<InputText class="form-control" @bind-Value="Model.Title" />
<div class="registry-sections">
<div class="mb-4">
<div class="section-header">
<h2>Registries you manage</h2>
<button class="btn btn-primary btn-sm" @onclick="() => ShowCreateForm = !ShowCreateForm">
<span class="bi bi-plus"></span> Create new
</button>
</div>
@if (MyRegistries.Count == 0)
{
<p class="text-muted">No registries yet.</p>
}
else
{
<div class="registry-grid">
@foreach (var registry in MyRegistries)
{
<div class="registry-card">
<h3>@registry.Title</h3>
<div class="registry-actions">
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">View</a>
<a href="/registry/@registry.Id/admin" class="btn btn-outline-secondary btn-sm">Manage</a>
</div>
</div>
}
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<InputSelect class="form-select" @bind-Value="Model.RegistryType">
<option value="@BirthList.Domain.Entities.RegistryType.Birth">Birth</option>
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">Wedding</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">Birthday</option>
</InputSelect>
}
</div>
<div class="mb-4">
<h2>Visited registries</h2>
@if (VisitedRegistries.Count == 0)
{
<p class="text-muted">No visited registries yet.</p>
}
else
{
<div class="registry-grid">
@foreach (var registry in VisitedRegistries)
{
<div class="registry-card">
<h3>@registry.Title</h3>
<div class="registry-actions">
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">View</a>
</div>
</div>
}
</div>
<div class="col-md-2">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="Model.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
}
</div>
</div>
@if (ShowCreateForm)
{
<div class="create-registry-modal-overlay" @onclick="() => ShowCreateForm = false">
<div class="create-registry-modal" @onclick:stopPropagation="true">
<div class="modal-header">
<h3 class="modal-title">Create new registry</h3>
<button type="button" class="btn-close" @onclick="() => ShowCreateForm = false"></button>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" type="submit">Create</button>
<div class="modal-body">
<EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Title</label>
<InputText class="form-control" @bind-Value="Model.Title" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<InputSelect class="form-select" @bind-Value="Model.RegistryType">
<option value="@BirthList.Domain.Entities.RegistryType.Birth">Birth</option>
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">Wedding</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">Birthday</option>
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="Model.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
</div>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<div class="alert alert-danger mb-3">@ErrorMessage</div>
}
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="() => ShowCreateForm = false">Cancel</button>
<button class="btn btn-primary" type="submit">Create</button>
</div>
</EditForm>
</div>
</div>
</EditForm>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<div class="alert alert-danger mt-3">@ErrorMessage</div>
}
</div>
<div class="mb-4">
<h2>Registries you manage</h2>
@if (MyRegistries.Count == 0)
{
<p>No registries yet.</p>
}
else
{
<ul>
@foreach (var registry in MyRegistries)
{
<li>
<a href="/registry/@registry.PublicLinkCode">@registry.Title</a>
&nbsp;|&nbsp;
<a href="/registry/@registry.Id/admin">Admin</a>
</li>
}
</ul>
}
</div>
<div>
<h2>Visited registries</h2>
@if (VisitedRegistries.Count == 0)
{
<p>No visited registries yet.</p>
}
else
{
<ul>
@foreach (var registry in VisitedRegistries)
{
<li><a href="/registry/@registry.PublicLinkCode">@registry.Title</a></li>
}
</ul>
}
</div>
</div>
}
</Authorized>
<NotAuthorized>
<p>Please <a href="Account/Login">log in</a> to create and manage registries.</p>
<div class="alert alert-info mt-4">
<p>Please <a href="Account/Login">log in</a> to create and manage registries.</p>
</div>
</NotAuthorized>
</AuthorizeView>
@code {
[SupplyParameterFromForm(FormName = "create-registry-form")]
protected RegistryCreateModel Model { get; set; } = new();
protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = [];
protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = [];
protected string? ErrorMessage { get; private set; }
protected bool ShowCreateForm { get; private set; }
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
await LoadAsync().ConfigureAwait(false);
}
protected async Task CreateRegistryAsync()
{
ErrorMessage = null;
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
ErrorMessage = "You must be logged in to create a registry.";
return;
}
if (string.IsNullOrWhiteSpace(Model.Title))
{
ErrorMessage = "Title is required.";
return;
}
await RegistryService.CreateRegistryAsync(userId, Model, CancellationToken.None).ConfigureAwait(false);
Model = new RegistryCreateModel
{
RegistryType = Model.RegistryType,
ThemeKey = Model.ThemeKey
};
ShowCreateForm = false;
await LoadAsync().ConfigureAwait(false);
}
private async Task LoadAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
MyRegistries = [];
VisitedRegistries = [];
return;
}
MyRegistries = await RegistryService.GetAdminRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
VisitedRegistries = await RegistryService.GetVisitedRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
}
}
@@ -1,62 +0,0 @@
using BirthList.Web.Features.Registries;
using Microsoft.AspNetCore.Components;
namespace BirthList.Web.Components.Pages;
public partial class Home : ComponentBase
{
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
[SupplyParameterFromForm(FormName = "create-registry-form")]
protected RegistryCreateModel Model { get; set; } = new();
protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = [];
protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = [];
protected string? ErrorMessage { get; private set; }
protected override async Task OnInitializedAsync()
{
await LoadAsync().ConfigureAwait(false);
}
protected async Task CreateRegistryAsync()
{
ErrorMessage = null;
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
ErrorMessage = "You must be logged in to create a registry.";
return;
}
if (string.IsNullOrWhiteSpace(Model.Title))
{
ErrorMessage = "Title is required.";
return;
}
await RegistryService.CreateRegistryAsync(userId, Model, CancellationToken.None).ConfigureAwait(false);
Model = new RegistryCreateModel
{
RegistryType = Model.RegistryType,
ThemeKey = Model.ThemeKey
};
await LoadAsync().ConfigureAwait(false);
}
private async Task LoadAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
MyRegistries = [];
VisitedRegistries = [];
return;
}
MyRegistries = await RegistryService.GetAdminRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
VisitedRegistries = await RegistryService.GetVisitedRegistriesAsync(userId, CancellationToken.None).ConfigureAwait(false);
}
}
@@ -1,7 +1,142 @@
h1 {
margin-bottom: 1rem;
margin-bottom: 2rem;
}
h2 {
margin-bottom: 0.75rem;
margin-bottom: 1.5rem;
}
.registry-sections {
max-width: 1200px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
margin: 0;
}
.registry-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.registry-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.registry-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.registry-card h3 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
color: #212529;
word-break: break-word;
}
.registry-actions {
display: flex;
gap: 0.5rem;
}
.registry-actions .btn {
flex: 1;
text-align: center;
}
.text-muted {
color: #6c757d;
font-style: italic;
}
/* Modal overlay */
.create-registry-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
}
.create-registry-modal {
background: white;
border-radius: 0.375rem;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
}
.btn-close {
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: #6c757d;
padding: 0.25rem;
}
.btn-close:hover {
color: #000;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
@media (max-width: 576px) {
.registry-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.section-header .btn {
width: 100%;
}
}
@@ -0,0 +1,105 @@
@page "/registry/{RegistryId:guid}/admin/action-log"
@rendermode InteractiveServer
@using BirthList.Web.Features.Registries
@using BirthList.Web.Authorization
<PageTitle>Action Log - Registry Admin</PageTitle>
@if (!IsAuthorized)
{
<p>Access denied.</p>
}
else
{
<h1>Registry Action Log</h1>
<p class="text-muted">This log shows all user actions on this registry: purchases, contributions, and other interactions.</p>
@if (ActionLogs is null)
{
<p>Loading...</p>
}
else if (ActionLogs.Count == 0)
{
<p class="text-muted">No actions recorded yet.</p>
}
else
{
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Date/Time</th>
<th>User</th>
<th>Action</th>
<th>Item</th>
<th>Quantity</th>
<th>Amount</th>
<th>Details</th>
</tr>
</thead>
<tbody>
@foreach (var log in ActionLogs)
{
<tr>
<td>@(log.CreatedAtUtc.LocalDateTime.ToString("d")) @(log.CreatedAtUtc.LocalDateTime.ToString("T"))</td>
<td>@log.UserDisplayName</td>
<td>
@switch (log.ActionType)
{
case "RegistryLinkOpened":
<span class="badge bg-info">Registry opened</span>
break;
case "ItemLinkOpened":
<span class="badge bg-info">Item link opened</span>
break;
case "MarkPurchased":
<span class="badge bg-success">Purchase marked</span>
break;
case "UnmarkPurchased":
<span class="badge bg-warning">Purchase unmarked</span>
break;
case "MarkPartialPurchase":
<span class="badge bg-primary">Partial purchase</span>
break;
case "LogContribution":
<span class="badge bg-secondary">Contribution logged</span>
break;
default:
<span class="badge bg-dark">@log.ActionType</span>
break;
}
</td>
<td>@(log.ItemName ?? "-")</td>
<td>@(log.Quantity > 0 ? log.Quantity.ToString() : "-")</td>
<td>@(log.Amount > 0 ? log.Amount.ToString("0.00") : "-")</td>
<td>@(string.IsNullOrWhiteSpace(log.Details) ? "-" : log.Details)</td>
</tr>
}
</tbody>
</table>
</div>
}
}
@code {
[Parameter] public Guid RegistryId { get; set; }
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryAuthorizationService AuthorizationService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
private IReadOnlyList<RegistryActionLogViewModel>? ActionLogs { get; set; }
private bool IsAuthorized { get; set; }
protected override async Task OnInitializedAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthorized = await AuthorizationService.IsRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false);
if (IsAuthorized)
{
ActionLogs = await RegistryService.GetRegistryActionLogsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -20,172 +20,289 @@ else
</div>
}
<section class="mb-4">
<h2>Current admins</h2>
@if (Admins.Count == 0)
{
<p>No admins assigned yet.</p>
}
else
{
<ul>
@foreach (var admin in Admins)
{
<li>@admin.DisplayName</li>
}
</ul>
}
</section>
<ul class="nav nav-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("items"))"
id="items-tab"
@onclick='() => SetActiveTab("items")'
type="button"
role="tab"
aria-controls="items-content"
aria-selected="@(ActiveTab == "items" ? "true" : "false")">
<span class="bi bi-box-seam"></span> Items
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("settings"))"
id="settings-tab"
@onclick='() => SetActiveTab("settings")'
type="button"
role="tab"
aria-controls="settings-content"
aria-selected="@(ActiveTab == "settings" ? "true" : "false")">
<span class="bi bi-gear"></span> Settings
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("admins"))"
id="admins-tab"
@onclick='() => SetActiveTab("admins")'
type="button"
role="tab"
aria-controls="admins-content"
aria-selected="@(ActiveTab == "admins" ? "true" : "false")">
<span class="bi bi-people"></span> Administrators
</button>
</li>
<li class="nav-item" role="presentation">
<a href="/registry/@RegistryId/admin/action-log" class="nav-link">
<span class="bi bi-clock-history"></span> Action Log
</a>
</li>
</ul>
<section class="mb-4">
<h2>Settings</h2>
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Baby name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
</div>
<div class="col-md-3">
<label class="form-label">Birth date</label>
<InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
</div>
<div class="col-md-3">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
</div>
</div>
<div class="tab-content">
<!-- Items Tab -->
<div class="tab-pane fade @(GetTabPaneClass("items"))" id="items-content" role="tabpanel" aria-labelledby="items-tab">
<section class="mb-4">
<h2>Add or edit item</h2>
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<InputText class="form-control" @bind-Value="ItemModel.Name" />
</div>
<div class="col-md-6">
<label class="form-label">Product URL</label>
<div class="input-group">
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" />
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">Auto fetch</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Picture URL</label>
<InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
</div>
<div class="col-md-6">
<label class="form-label">Description</label>
<InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
</div>
<div class="col-md-2">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
</div>
<div class="col-md-2">
<label class="form-label">Desired qty</label>
<InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
<label>Participation</label>
</div>
<div class="col-md-3">
<label class="form-label">Participation target</label>
<InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
</div>
<div class="col-md-3">
<label class="form-label">Second hand preference</label>
<InputSelect class="form-select" @bind-Value="ItemModel.PreferSecondHand">
<option value="">Second hand optional</option>
<option value="true">Prefer second hand</option>
<option value="false">New only</option>
</InputSelect>
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save item</button>
</EditForm>
</section>
<div class="mt-3">
<label class="form-label">Shipping address</label>
<InputTextArea class="form-control" @bind-Value="SettingsModel.ShippingAddress" rows="3" />
</div>
<div class="mt-3">
<label class="form-label">Top content</label>
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" />
</div>
<div class="row g-3 mt-2">
<div class="col-md-4">
<label class="form-label">Bank account name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
</div>
<div class="col-md-4">
<label class="form-label">IBAN</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
</div>
<div class="col-md-4">
<label class="form-label">BIC</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save settings</button>
</EditForm>
</section>
<section class="mb-4">
<h2>Invite admin</h2>
<div class="row g-2">
<div class="col-md-8">
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="optional email" />
</div>
<div class="col-md-4">
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">Create invite</button>
</div>
<section>
<h2>Items</h2>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th>Purchased by / Contributed by</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
{
<tr>
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>Purchased:</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>Contributed:</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</section>
</div>
@if (!string.IsNullOrWhiteSpace(InviteLink))
{
<p class="mt-2">Invite link: <a href="@InviteLink">@InviteLink</a></p>
}
</section>
<section>
<h2>Add or edit item</h2>
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<InputText class="form-control" @bind-Value="ItemModel.Name" />
</div>
<div class="col-md-6">
<label class="form-label">Product URL</label>
<div class="input-group">
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" />
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">Auto fetch</button>
<!-- Settings Tab -->
<div class="tab-pane fade @(GetTabPaneClass("settings"))" id="settings-content" role="tabpanel" aria-labelledby="settings-tab">
<section class="mb-4">
<h2>Registry Settings</h2>
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Baby name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
</div>
<div class="col-md-3">
<label class="form-label">Birth date</label>
<InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
</div>
<div class="col-md-3">
<label class="form-label">Theme</label>
<InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
</InputSelect>
</div>
</div>
<div class="mt-3">
<label class="form-label">Shipping address</label>
<InputTextArea class="form-control" @bind-Value="SettingsModel.ShippingAddress" rows="4" />
<small class="form-text text-muted">Line breaks will be preserved</small>
</div>
<div class="mt-3">
<label class="form-label">Top content</label>
<div class="editor-wrapper">
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" ToolbarContent="@ToolbarContent" />
</div>
</div>
<div class="mt-3">
<h3>Bank Account Settings</h3>
<div class="row g-3 mt-2">
<div class="col-md-4">
<label class="form-label">Bank account name</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
</div>
<div class="col-md-4">
<label class="form-label">IBAN</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
</div>
<div class="col-md-4">
<label class="form-label">BIC</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
</div>
</div>
<div class="mt-2">
<div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="SettingsModel.ShowBankAccountName" id="showBankName" />
<label class="form-check-label" for="showBankName">
Display bank account name
</label>
</div>
</div>
</div>
<button class="btn btn-primary mt-4" type="submit">Save settings</button>
</EditForm>
</section>
</div>
<!-- Admins Tab -->
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
<section class="mb-4">
<h2>Current administrators</h2>
@if (Admins.Count == 0)
{
<p class="text-muted">No admins assigned yet.</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Email / Name</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var admin in Admins)
{
<tr>
<td>@admin.DisplayName</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAdminAsync(admin.UserId)">Remove</button>
</td>
</tr>
}
</tbody>
</table>
}
</section>
<section>
<h2>Invite administrator</h2>
<div class="row g-2">
<div class="col-md-8">
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="optional email" />
</div>
<div class="col-md-4">
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">Create invite</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Picture URL</label>
<InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
</div>
<div class="col-md-6">
<label class="form-label">Description</label>
<InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
</div>
<div class="col-md-2">
<label class="form-label">Price</label>
<InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
</div>
<div class="col-md-2">
<label class="form-label">Desired qty</label>
<InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
<label>Participation</label>
</div>
<div class="col-md-3">
<label class="form-label">Participation target</label>
<InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.CanBeSecondHand" />
<label>Second hand allowed</label>
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save item</button>
</EditForm>
<table class="table table-striped mt-4">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
@if (!string.IsNullOrWhiteSpace(InviteLink))
{
<tr>
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
<p class="mt-3">
<strong>Invite link:</strong>
<br />
<a href="@InviteLink" target="_blank">@InviteLink</a>
</p>
}
</tbody>
</table>
</section>
</section>
</div>
</div>
}
@@ -30,6 +30,14 @@ public partial class RegistryAdmin : ComponentBase
protected string? InviteEmail { get; set; }
protected string? InviteLink { get; private set; }
protected BlazoredTextEditor? TextEditor { get; set; }
protected string ActiveTab { get; set; } = "items";
protected RenderFragment ToolbarContent => builder =>
{
builder.AddMarkupContent(0, "<span class='ql-formats'><select class='ql-header'><option selected></option><option value='1'></option><option value='2'></option></select></span>");
builder.AddMarkupContent(1, "<span class='ql-formats'><button class='ql-bold'></button><button class='ql-italic'></button><button class='ql-underline'></button></span>");
builder.AddMarkupContent(2, "<span class='ql-formats'><button class='ql-list' value='ordered'></button><button class='ql-list' value='bullet'></button></span>");
builder.AddMarkupContent(3, "<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
};
protected override async Task OnParametersSetAsync()
{
@@ -71,6 +79,21 @@ public partial class RegistryAdmin : ComponentBase
}
}
protected void SetActiveTab(string tab)
{
ActiveTab = tab;
}
protected string GetTabClass(string tab)
{
return ActiveTab == tab ? "active" : "";
}
protected string GetTabPaneClass(string tab)
{
return ActiveTab == tab ? "show active" : "";
}
protected async Task SaveSettingsAsync()
{
if (TextEditor is not null)
@@ -128,7 +151,7 @@ public partial class RegistryAdmin : ComponentBase
DesiredQuantity = model.DesiredQuantity,
ParticipationAllowed = model.ParticipationAllowed,
ParticipationTargetAmount = model.ParticipationTargetAmount,
CanBeSecondHand = model.CanBeSecondHand,
PreferSecondHand = model.PreferSecondHand,
IsGiven = model.IsGiven
};
}
@@ -195,6 +218,17 @@ public partial class RegistryAdmin : ComponentBase
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task DeleteAdminAsync(string? userId)
{
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
await RegistryService.RemoveRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false);
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
private async Task LoadAsync()
{
var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
@@ -209,6 +243,7 @@ public partial class RegistryAdmin : ComponentBase
SettingsModel.BankAccountDisplayName = settings.BankAccountDisplayName;
SettingsModel.BankAccountIban = settings.BankAccountIban;
SettingsModel.BankAccountBic = settings.BankAccountBic;
SettingsModel.ShowBankAccountName = settings.ShowBankAccountName;
}
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
@@ -1,3 +1,160 @@
section {
margin-bottom: 2rem;
}
h1 {
margin-bottom: 1.5rem;
}
h2 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
/* Tabs styling */
.nav-tabs {
border-bottom: 2px solid #dee2e6;
}
.nav-tabs .nav-link {
border: none;
border-bottom: 3px solid transparent;
color: #6c757d;
margin-bottom: -2px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-tabs .nav-link:hover {
border-bottom-color: #0d6efd;
color: #0d6efd;
}
.nav-tabs .nav-link.active {
border-bottom-color: #0d6efd;
color: #0d6efd;
background-color: transparent;
}
.tab-content {
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.tab-pane {
display: none;
}
.tab-pane.show.active {
display: block;
}
/* Editor styling */
.editor-wrapper {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
overflow: hidden;
background-color: #fff;
}
.editor-wrapper ::deep .ql-container {
font-family: inherit;
font-size: 1rem;
}
.editor-wrapper ::deep .ql-editor {
min-height: 200px;
padding: 15px;
line-height: 1.5;
}
.editor-wrapper ::deep .ql-toolbar {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.editor-wrapper ::deep .ql-toolbar.ql-snow {
padding: 10px;
}
.editor-wrapper ::deep .ql-snow .ql-picker-label {
color: #495057;
}
.editor-wrapper ::deep .ql-snow .ql-stroke {
stroke: #495057;
}
.editor-wrapper ::deep .ql-snow .ql-fill {
fill: #495057;
}
.editor-wrapper ::deep .ql-snow .ql-picker.ql-expanded .ql-picker-item.selected {
color: #0d6efd;
}
/* Table styling */
.table {
margin-top: 1rem;
}
.table thead th {
background-color: #f8f9fa;
border-top: 2px solid #dee2e6;
font-weight: 600;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
/* Form styling */
.form-check {
margin-top: 0.75rem;
}
.form-check-input {
margin-right: 0.5rem;
}
.text-muted {
color: #6c757d;
}
/* Alert styling */
.alert {
margin-bottom: 1.5rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.nav-tabs .nav-link {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
}
.table {
font-size: 0.9rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
}
@@ -1,4 +1,5 @@
@page "/registry/{Code}"
@rendermode InteractiveServer
@using BirthList.Web.Features.Registries
@@ -22,11 +23,11 @@ else
{
<div class="alert alert-info">
<strong>Shipping address</strong><br />
@Registry.ShippingAddress
<span class="shipping-address-text">@Registry.ShippingAddress</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountIban))
@if (Registry.ShowBankAccountName && !string.IsNullOrWhiteSpace(Registry.BankAccountIban))
{
<div class="alert alert-secondary">
<strong>Bank transfer participation</strong><br />
@@ -42,16 +43,26 @@ else
<div class="item-grid">
@foreach (var item in Registry.Items)
{
<article class="card item-card">
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<h5 class="card-title">@item.Name</h5>
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
{
<span class="badge bg-info">Second-hand preferred</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">Second-hand optional</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text">@item.Description</p>
<p class="card-text item-description-text">@item.Description</p>
}
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
@if (item.PriceAmount.HasValue)
@@ -63,6 +74,54 @@ else
<p class="mb-2"><strong>Participation:</strong> @item.MoneyFulfilledAmount.ToString("0.00") / @item.ParticipationTargetAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span>
@if (item.Purchasers.Count > 1)
{
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span>
}
</div>
}
else
{
<span class="text-muted small">Purchased</span>
}
</div>
}
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
<strong class="text-sm">Contributed by:</strong>
<div class="contributor-list">
@foreach (var contributor in item.Contributors)
{
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
}
</div>
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
@@ -70,11 +129,22 @@ else
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl))
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<a class="btn btn-primary btn-sm" href="@item.ProductUrl" target="_blank" @onclick="() => BeginPurchase(item.Id)">Purchase</a>
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">I transferred money</button>
@@ -95,8 +165,16 @@ else
<h3>Mark as purchased</h3>
<p>How many units did you purchase?</p>
<InputNumber class="form-control" @bind-Value="PurchasedQuantity" />
@if (!string.IsNullOrWhiteSpace(PurchaseItemUrl))
{
<div class="mt-3">
<a href="@PurchaseItemUrl" target="_blank" class="btn btn-outline-primary btn-sm w-100">Open product link</a>
</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm</button>
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm purchase</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">Cancel</button>
</div>
</div>
@@ -118,3 +196,26 @@ else
</div>
</div>
}
@if (ShowPurchaserSelectionPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Select purchaser to unmark</h3>
<p>Multiple users have purchased this item. Choose which purchase to unmark:</p>
<div class="purchaser-list">
@foreach (var purchaser in PurchasersToUnmark)
{
<div class="purchaser-item">
<span>@purchaser.DisplayName (@purchaser.Quantity)</span>
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaserAsync(purchaser.UserId)">Unmark</button>
</div>
}
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-danger" @onclick="UnmarkAllPurchasersAsync">Unmark all</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaserSelectionPrompt">Cancel</button>
</div>
</div>
</div>
}
@@ -1,5 +1,7 @@
using BirthList.Domain.Entities;
using BirthList.Web.Features.Registries;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
namespace BirthList.Web.Components.Pages;
@@ -11,21 +13,30 @@ public partial class RegistryPublic : ComponentBase
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
[Inject] private RegistryThemeService RegistryThemeService { get; set; } = null!;
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
protected RegistryPublicViewModel? Registry { get; private set; }
protected bool IsAuthenticated { get; private set; }
protected bool ShowPurchasePrompt { get; private set; }
protected bool ShowContributionPrompt { get; private set; }
protected bool ShowPurchaserSelectionPrompt { get; private set; }
protected Guid ActiveItemId { get; private set; }
protected int PurchasedQuantity { get; set; } = 1;
protected decimal ContributionAmount { get; set; }
protected string ContributionMessage { get; set; } = string.Empty;
protected string? PurchaseItemUrl { get; private set; }
protected IReadOnlyList<ItemContributorViewModel> PurchasersToUnmark { get; private set; } = [];
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthenticated = !string.IsNullOrWhiteSpace(userId);
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
if (Registry is not null && !string.IsNullOrWhiteSpace(userId))
{
await RegistryService.LogUserActionAsync(Registry.Id, userId, UserActionType.RegistryLinkOpened, null, null, CancellationToken.None).ConfigureAwait(false);
}
}
protected void LoginRedirect()
@@ -39,10 +50,23 @@ public partial class RegistryPublic : ComponentBase
PurchasedQuantity = 1;
}
protected void OpenPurchasePrompt(Guid itemId)
protected async Task OpenPurchasePrompt(Guid itemId, bool openTab = false)
{
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
PurchasedQuantity = 1;
PurchaseItemUrl = item.ProductUrl;
if (openTab && !string.IsNullOrWhiteSpace(item.ProductUrl))
{
await JSRuntime.InvokeVoidAsync("open", item.ProductUrl, "_blank");
}
ShowPurchasePrompt = true;
}
@@ -50,6 +74,7 @@ public partial class RegistryPublic : ComponentBase
{
ShowPurchasePrompt = false;
ActiveItemId = Guid.Empty;
PurchaseItemUrl = null;
}
protected async Task ConfirmPurchaseAsync()
@@ -94,4 +119,69 @@ public partial class RegistryPublic : ComponentBase
ShowContributionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task UnmarkPurchaseAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
// If admin with multiple purchasers, show selection modal
if (Registry.IsAdmin)
{
ActiveItemId = itemId;
PurchasersToUnmark = item.Purchasers;
ShowPurchaserSelectionPrompt = true;
return;
}
// Regular user or single purchaser - unmark directly
await RegistryService.UnmarkPurchaseAsync(itemId, userId, CancellationToken.None).ConfigureAwait(false);
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected void ClosePurchaserSelectionPrompt()
{
ShowPurchaserSelectionPrompt = false;
ActiveItemId = Guid.Empty;
PurchasersToUnmark = [];
}
protected async Task UnmarkPurchaserAsync(string purchaserUserId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, purchaserUserId, userId, CancellationToken.None).ConfigureAwait(false);
ShowPurchaserSelectionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task UnmarkAllPurchasersAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
foreach (var purchaser in PurchasersToUnmark)
{
await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, purchaser.UserId, userId, CancellationToken.None).ConfigureAwait(false);
}
ShowPurchaserSelectionPrompt = false;
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
}
@@ -11,8 +11,33 @@
}
.item-image {
max-height: 220px;
object-fit: cover;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
object-position: center;
background-color: #f8f9fa;
}
.item-given {
opacity: 0.75;
background-color: #f0f0f0;
}
.item-given .item-image {
background-color: #e8e8e8;
}
.item-given .card-body {
background-color: #b0b0b0;
}
.item-given button {
opacity: 0.3;
/*background-color: #b0b0b0;*/
}
.item-given .btn-warning {
opacity: 1;
}
.prompt-overlay {
@@ -43,3 +68,76 @@
.theme-birthday-default h1 {
color: #198754;
}
.shipping-address-text,
.item-description-text {
white-space: pre-line;
}
.card-header-badges {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.card-header-badges .card-title {
margin: 0;
flex: 1;
}
.card-header-badges .badge {
white-space: nowrap;
flex-shrink: 0;
}
.contributors-section {
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
font-size: 0.875rem;
}
.contributor-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
.contributor-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: #e9ecef;
border-radius: 3px;
font-size: 0.8rem;
}
.text-sm {
font-size: 0.875rem;
}
.purchaser-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin: 1rem 0;
}
.purchaser-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 4px;
}
.purchaser-item span {
flex: 1;
}
.purchaser-item .btn {
margin-left: 0.5rem;
}