Initial Blazor birth registry app, theming, and services
Build and Push Docker Image / build-and-push (push) Failing after 10s
Build and Push Docker Image / build-and-push (push) Failing after 10s
Implemented a Blazor Web App (.NET 8) for a public-by-link birth registry platform, following project guidelines. Added domain entities, EF Core context, and Blazor components for authentication, registry management, and public views. Introduced core services for registries, theming, user context, platform owner bootstrapping, and SMTP email. Included static assets (Bootstrap, favicon), launch settings, Dockerfile, CI workflow, and deployment configs. Added bootstrap.min.css.map for improved CSS debugging.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Birth Registry</PageTitle>
|
||||
|
||||
<h1>Birth Registry</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>
|
||||
<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="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 class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" type="submit">Create</button>
|
||||
</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>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<p>Please <a href="Account/Login">log in</a> to create and manage registries.</p>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
@@ -0,0 +1,62 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
@page "/registry/{RegistryId:guid}/admin"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using Blazored.TextEditor
|
||||
|
||||
<PageTitle>Registry Admin</PageTitle>
|
||||
|
||||
@if (!IsAuthorized)
|
||||
{
|
||||
<p>Access denied.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Registry Admin</h1>
|
||||
|
||||
@if (!IsSmtpConfigured)
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
SMTP is not configured. Email features (identity emails and admin invite emails) are disabled. Configure the Smtp section in appsettings or user secrets.
|
||||
</div>
|
||||
}
|
||||
|
||||
<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="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>
|
||||
</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>
|
||||
</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)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using BirthList.Web.Authorization;
|
||||
using BirthList.Web.Features.Registries;
|
||||
using BirthList.Web.Services;
|
||||
using Blazored.TextEditor;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Pages;
|
||||
|
||||
[Authorize]
|
||||
public partial class RegistryAdmin : ComponentBase
|
||||
{
|
||||
[Parameter] public Guid RegistryId { get; set; }
|
||||
|
||||
[Inject] private RegistryService RegistryService { get; set; } = null!;
|
||||
[Inject] private RegistryMetadataService RegistryMetadataService { get; set; } = null!;
|
||||
[Inject] private RegistryAuthorizationService RegistryAuthorizationService { get; set; } = null!;
|
||||
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
|
||||
[Inject] private SmtpEmailSender EmailSender { get; set; } = null!;
|
||||
[Inject] private SmtpConfigurationStatusService SmtpConfigurationStatusService { get; set; } = null!;
|
||||
|
||||
protected RegistrySettingsEditModel SettingsModel { get; } = new();
|
||||
protected RegistryItemEditModel ItemModel { get; private set; } = new();
|
||||
protected IReadOnlyList<RegistryItemEditModel> Items { get; private set; } = [];
|
||||
protected bool IsAuthorized { get; private set; }
|
||||
protected bool IsSmtpConfigured { get; private set; }
|
||||
protected string? InviteEmail { get; set; }
|
||||
protected string? InviteLink { get; private set; }
|
||||
protected BlazoredTextEditor? TextEditor { get; set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
IsAuthorized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
IsAuthorized = await RegistryAuthorizationService.IsRegistryAdminAsync(RegistryId, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!IsAuthorized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsSmtpConfigured = SmtpConfigurationStatusService.IsConfigured();
|
||||
await LoadAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || TextEditor is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
|
||||
{
|
||||
await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SaveSettingsAsync()
|
||||
{
|
||||
if (TextEditor is not null)
|
||||
{
|
||||
SettingsModel.HeaderContentHtml = await TextEditor.GetHTML().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await RegistryService.UpdateRegistrySettingsAsync(RegistryId, SettingsModel, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async Task SaveItemAsync()
|
||||
{
|
||||
await RegistryService.UpsertRegistryItemAsync(RegistryId, ItemModel, CancellationToken.None).ConfigureAwait(false);
|
||||
ItemModel = new RegistryItemEditModel
|
||||
{
|
||||
CurrencyCode = SettingsModel.CurrencyCode,
|
||||
DesiredQuantity = 1
|
||||
};
|
||||
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected async Task DeleteItemAsync(Guid? itemId)
|
||||
{
|
||||
if (!itemId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistryService.DeleteRegistryItemAsync(RegistryId, itemId.Value, CancellationToken.None).ConfigureAwait(false);
|
||||
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected void EditItem(RegistryItemEditModel model)
|
||||
{
|
||||
ItemModel = new RegistryItemEditModel
|
||||
{
|
||||
Id = model.Id,
|
||||
Name = model.Name,
|
||||
PictureUrl = model.PictureUrl,
|
||||
ProductUrl = model.ProductUrl,
|
||||
Description = model.Description,
|
||||
PriceAmount = model.PriceAmount,
|
||||
CurrencyCode = model.CurrencyCode,
|
||||
DesiredQuantity = model.DesiredQuantity,
|
||||
ParticipationAllowed = model.ParticipationAllowed,
|
||||
ParticipationTargetAmount = model.ParticipationTargetAmount,
|
||||
CanBeSecondHand = model.CanBeSecondHand,
|
||||
IsGiven = model.IsGiven
|
||||
};
|
||||
}
|
||||
|
||||
protected async Task FetchMetadataAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.ProductUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false);
|
||||
if (metadata is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.Name) && !string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
ItemModel.Name = metadata.Title;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.Description) && !string.IsNullOrWhiteSpace(metadata.Description))
|
||||
{
|
||||
ItemModel.Description = metadata.Description;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl))
|
||||
{
|
||||
ItemModel.PictureUrl = metadata.ImageUrl;
|
||||
}
|
||||
|
||||
if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue)
|
||||
{
|
||||
ItemModel.PriceAmount = metadata.PriceAmount;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ItemModel.CurrencyCode) && !string.IsNullOrWhiteSpace(metadata.CurrencyCode))
|
||||
{
|
||||
ItemModel.CurrencyCode = metadata.CurrencyCode;
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task CreateInviteAsync()
|
||||
{
|
||||
var inviteId = await RegistryService.CreateAdminInviteAsync(RegistryId, InviteEmail, TimeSpan.FromDays(7), CancellationToken.None).ConfigureAwait(false);
|
||||
var token = await RegistryService.GetInviteTokenAsync(inviteId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InviteLink = NavigationManager.ToAbsoluteUri($"/registry/{RegistryId}/invite/{Uri.EscapeDataString(token)}").AbsoluteUri;
|
||||
if (!string.IsNullOrWhiteSpace(InviteEmail))
|
||||
{
|
||||
await EmailSender.SendInviteAsync(InviteEmail, InviteLink).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (settings is not null)
|
||||
{
|
||||
SettingsModel.BabyName = settings.BabyName;
|
||||
SettingsModel.BirthDate = settings.BirthDate;
|
||||
SettingsModel.HeaderContentHtml = settings.HeaderContentHtml;
|
||||
SettingsModel.ShippingAddress = settings.ShippingAddress;
|
||||
SettingsModel.CurrencyCode = settings.CurrencyCode;
|
||||
SettingsModel.ThemeKey = settings.ThemeKey;
|
||||
SettingsModel.BankAccountDisplayName = settings.BankAccountDisplayName;
|
||||
SettingsModel.BankAccountIban = settings.BankAccountIban;
|
||||
SettingsModel.BankAccountBic = settings.BankAccountBic;
|
||||
}
|
||||
|
||||
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||
ItemModel = new RegistryItemEditModel
|
||||
{
|
||||
CurrencyCode = SettingsModel.CurrencyCode,
|
||||
DesiredQuantity = 1
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/registry/{RegistryId:guid}/invite/{Token}"
|
||||
|
||||
<PageTitle>Admin Invite</PageTitle>
|
||||
|
||||
<h1>Admin invitation</h1>
|
||||
|
||||
@if (Redeemed is null)
|
||||
{
|
||||
<p>Validating invitation...</p>
|
||||
}
|
||||
else if (Redeemed.Value)
|
||||
{
|
||||
<p>Invitation accepted. You are now an admin.</p>
|
||||
<a class="btn btn-primary" href="/registry/@RegistryId/admin">Go to admin</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>The invitation is invalid or already used.</p>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using BirthList.Web.Features.Registries;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Pages;
|
||||
|
||||
[Authorize]
|
||||
public partial class RegistryInvite : ComponentBase
|
||||
{
|
||||
[Parameter] public Guid RegistryId { get; set; }
|
||||
[Parameter] public string Token { get; set; } = string.Empty;
|
||||
|
||||
[Inject] private RegistryService RegistryService { get; set; } = null!;
|
||||
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
|
||||
|
||||
protected bool? Redeemed { get; private set; }
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(Token))
|
||||
{
|
||||
Redeemed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Redeemed = await RegistryService.RedeemAdminInviteAsync(Token, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
@page "/registry/{Code}"
|
||||
|
||||
@using BirthList.Web.Features.Registries
|
||||
|
||||
<PageTitle>Registry</PageTitle>
|
||||
|
||||
@if (Registry is null)
|
||||
{
|
||||
<p>Registry not found.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)">
|
||||
<h1>@(string.IsNullOrWhiteSpace(Registry.BabyName) ? Registry.Title : Registry.BabyName)</h1>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.HeaderContentHtml))
|
||||
{
|
||||
<div class="header-content">@((MarkupString)Registry.HeaderContentHtml)</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.ShippingAddress))
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<strong>Shipping address</strong><br />
|
||||
@Registry.ShippingAddress
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountIban))
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
<strong>Bank transfer participation</strong><br />
|
||||
@Registry.BankAccountDisplayName<br />
|
||||
IBAN: @Registry.BankAccountIban
|
||||
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
|
||||
{
|
||||
<span> | BIC: @Registry.BankAccountBic</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="item-grid">
|
||||
@foreach (var item in Registry.Items)
|
||||
{
|
||||
<article class="card item-card">
|
||||
@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>
|
||||
@if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
<p class="card-text">@item.Description</p>
|
||||
}
|
||||
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
|
||||
@if (item.PriceAmount.HasValue)
|
||||
{
|
||||
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
|
||||
}
|
||||
@if (item.ParticipationAllowed && item.ParticipationTargetAmount.HasValue)
|
||||
{
|
||||
<p class="mb-2"><strong>Participation:</strong> @item.MoneyFulfilledAmount.ToString("0.00") / @item.ParticipationTargetAmount.Value.ToString("0.00") @item.CurrencyCode</p>
|
||||
}
|
||||
|
||||
@if (!IsAuthenticated)
|
||||
{
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
@if (!string.IsNullOrWhiteSpace(item.ProductUrl))
|
||||
{
|
||||
<a class="btn btn-primary btn-sm" href="@item.ProductUrl" target="_blank" @onclick="() => BeginPurchase(item.Id)">Purchase</a>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowPurchasePrompt)
|
||||
{
|
||||
<div class="prompt-overlay">
|
||||
<div class="prompt-card">
|
||||
<h3>Mark as purchased</h3>
|
||||
<p>How many units did you purchase?</p>
|
||||
<InputNumber class="form-control" @bind-Value="PurchasedQuantity" />
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ShowContributionPrompt)
|
||||
{
|
||||
<div class="prompt-overlay">
|
||||
<div class="prompt-card">
|
||||
<h3>Log contribution</h3>
|
||||
<p>Transferred amount</p>
|
||||
<InputNumber class="form-control" @bind-Value="ContributionAmount" />
|
||||
<p class="mt-2">Message: @ContributionMessage</p>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-success" @onclick="ConfirmContributionAsync">Confirm</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using BirthList.Web.Features.Registries;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace BirthList.Web.Components.Pages;
|
||||
|
||||
public partial class RegistryPublic : ComponentBase
|
||||
{
|
||||
[Parameter] public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Inject] private RegistryService RegistryService { get; set; } = null!;
|
||||
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
|
||||
[Inject] private RegistryThemeService RegistryThemeService { get; set; } = null!;
|
||||
[Inject] private NavigationManager NavigationManager { 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 Guid ActiveItemId { get; private set; }
|
||||
protected int PurchasedQuantity { get; set; } = 1;
|
||||
protected decimal ContributionAmount { get; set; }
|
||||
protected string ContributionMessage { get; set; } = string.Empty;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
protected void LoginRedirect()
|
||||
{
|
||||
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString($"/registry/{Code}")}", forceLoad: true);
|
||||
}
|
||||
|
||||
protected void BeginPurchase(Guid itemId)
|
||||
{
|
||||
ActiveItemId = itemId;
|
||||
PurchasedQuantity = 1;
|
||||
}
|
||||
|
||||
protected void OpenPurchasePrompt(Guid itemId)
|
||||
{
|
||||
ActiveItemId = itemId;
|
||||
PurchasedQuantity = 1;
|
||||
ShowPurchasePrompt = true;
|
||||
}
|
||||
|
||||
protected void ClosePurchasePrompt()
|
||||
{
|
||||
ShowPurchasePrompt = false;
|
||||
ActiveItemId = Guid.Empty;
|
||||
}
|
||||
|
||||
protected async Task ConfirmPurchaseAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistryService.AddPurchaseAsync(ActiveItemId, userId, PurchasedQuantity, CancellationToken.None).ConfigureAwait(false);
|
||||
ShowPurchasePrompt = false;
|
||||
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected void OpenContributionPrompt(Guid itemId)
|
||||
{
|
||||
ActiveItemId = itemId;
|
||||
ContributionAmount = 0;
|
||||
ContributionMessage = Registry?.Items.FirstOrDefault(x => x.Id == itemId) is { } item
|
||||
? $"Participation for item: {item.Name}"
|
||||
: string.Empty;
|
||||
ShowContributionPrompt = true;
|
||||
}
|
||||
|
||||
protected void CloseContributionPrompt()
|
||||
{
|
||||
ShowContributionPrompt = false;
|
||||
ActiveItemId = Guid.Empty;
|
||||
ContributionMessage = string.Empty;
|
||||
}
|
||||
|
||||
protected async Task ConfirmContributionAsync()
|
||||
{
|
||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty || ContributionAmount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false);
|
||||
ShowContributionPrompt = false;
|
||||
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.registry-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.item-image {
|
||||
max-height: 220px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.prompt-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.prompt-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
width: min(420px, 95vw);
|
||||
}
|
||||
|
||||
.theme-birth-default h1 {
|
||||
color: #d36e70;
|
||||
}
|
||||
|
||||
.theme-wedding-default h1 {
|
||||
color: #8257e6;
|
||||
}
|
||||
|
||||
.theme-birthday-default h1 {
|
||||
color: #198754;
|
||||
}
|
||||
Reference in New Issue
Block a user