Add UserActionLog and enhance registry features
Build and Push Docker Image / build-and-push (push) Successful in 1m59s
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:
@@ -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>
|
||||
|
|
||||
<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user