diff --git a/UIs/Merchants/Comanda.Merchants.WebUI.sln b/UIs/Merchants/Comanda.Merchants.WebUI.sln new file mode 100644 index 0000000..b1d536c --- /dev/null +++ b/UIs/Merchants/Comanda.Merchants.WebUI.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11312.210 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Source", "Source", "{B8EFCA5F-814F-285C-A8CB-F00F14650265}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Comanda.Merchants.WebUI", "Source\Comanda.Merchants.WebUI.csproj", "{C835E518-C45B-4575-93EC-78103FD20F9B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C835E518-C45B-4575-93EC-78103FD20F9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Debug|x64.ActiveCfg = Debug|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Debug|x64.Build.0 = Debug|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Debug|x86.ActiveCfg = Debug|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Debug|x86.Build.0 = Debug|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Release|Any CPU.Build.0 = Release|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Release|x64.ActiveCfg = Release|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Release|x64.Build.0 = Release|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Release|x86.ActiveCfg = Release|Any CPU + {C835E518-C45B-4575-93EC-78103FD20F9B}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C835E518-C45B-4575-93EC-78103FD20F9B} = {B8EFCA5F-814F-285C-A8CB-F00F14650265} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B38CFDDE-933A-4367-B105-C867F91B05BD} + EndGlobalSection +EndGlobal diff --git a/UIs/Merchants/Source/App.razor b/UIs/Merchants/Source/App.razor new file mode 100644 index 0000000..1e57ce8 --- /dev/null +++ b/UIs/Merchants/Source/App.razor @@ -0,0 +1,80 @@ +@inject IProfilesClient profilesClient +@inject IPrincipalProvider principalProvider +@inject IStoreClient storeClient +@inject ISessionManager sessionManager +@inject IIdentityClient identityClient +@inject ILocalStorageGateway localStorage +@inject AuthenticationStateProvider authenticationStateProvider +@inject NavigationManager navigationManager + + + + + + + + + + + + + + +@code { + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + await InitializeMockSessionAsync(); + await SetPrincipalAsync(); + + var session = await authenticationStateProvider.GetAuthenticationStateAsync(); + var principal = session.User; + + if (principal?.Identity?.IsAuthenticated is true) + { + var identifier = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var owners = await profilesClient.GetOwnersAsync(new() { UserId = identifier! }); + + var owner = owners?.Data?.Items.FirstOrDefault(); + if (owner is null) + return; + + var establishments = await storeClient.GetEstablishmentsAsync(new() { OwnerId = owner.Identifier }); + var establishment = establishments?.Data?.Items.FirstOrDefault(); + + if (establishment is null) + { + navigationManager.NavigateTo("/onboarding"); + return; + } + + await localStorage.SetAsync(Storage.Establishment, establishment); + await localStorage.SetAsync(Storage.Merchant, owner); + } + + } + + private async Task SetPrincipalAsync() + { + var principal = await principalProvider.GetPrincipalAsync(); + if (principal.IsFailure || principal.Data is null) + return; + + await localStorage.SetAsync(Storage.Principal, principal.Data); + } + + private async Task InitializeMockSessionAsync() + { + var authentication = await identityClient.AuthenticateAsync(new() + { + Username = "jane.doe@email.com", + Password = "password" + }); + + if (authentication.IsFailure || authentication.Data is null) + return; + + await sessionManager.SignInAsync(authentication.Data.AccessToken, authentication.Data.RefreshToken); + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Authentication/JwtAuthenticationStateProvider.cs b/UIs/Merchants/Source/Authentication/JwtAuthenticationStateProvider.cs new file mode 100644 index 0000000..1998d91 --- /dev/null +++ b/UIs/Merchants/Source/Authentication/JwtAuthenticationStateProvider.cs @@ -0,0 +1,34 @@ +namespace Comanda.Merchants.WebUI.Authentication; + +public sealed class JwtAuthenticationStateProvider(ILocalStorageGateway localStorage) : + AuthenticationStateProvider +{ + private readonly JwtSecurityTokenHandler tokenHandler = new(); + private readonly ClaimsPrincipal anonymous = + new(new ClaimsIdentity()); + + public override async Task GetAuthenticationStateAsync() + { + var tokenString = await localStorage.GetAsStringAsync(Storage.SecurityToken); + if (string.IsNullOrWhiteSpace(tokenString)) + { + return new AuthenticationState(anonymous); + } + + var token = tokenHandler.ReadJwtToken(tokenString); + if (token.ValidTo < DateTime.UtcNow) + { + return new AuthenticationState(anonymous); + } + + var identity = new ClaimsIdentity(token.Claims, "https://www.rfc-editor.org/rfc/rfc7519", nameType: "preferred_username", roleType: "role"); + var principal = new ClaimsPrincipal(identity); + + return new AuthenticationState(principal); + } + + public void NotifyAuthenticationStateChanged() + { + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } +} diff --git a/UIs/Merchants/Source/Authentication/PrincipalProvider.cs b/UIs/Merchants/Source/Authentication/PrincipalProvider.cs new file mode 100644 index 0000000..febe92d --- /dev/null +++ b/UIs/Merchants/Source/Authentication/PrincipalProvider.cs @@ -0,0 +1,33 @@ +namespace Comanda.Merchants.WebUI.Authentication; + +public sealed class PrincipalProvider(HttpClient httpClient) : IPrincipalProvider +{ + private readonly JsonSerializerOptions serializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public async Task> GetPrincipalAsync(CancellationToken cancellation = default) + { + var response = await httpClient.GetAsync("/api/v1/identity/principal", cancellation); + var content = await response.Content.ReadAsStringAsync(cancellation); + + // we prefer explicit boolean comparisons for readability + // https://rules.sonarsource.com/csharp/RSPEC-1125/ + + #pragma warning disable S1125 + if (response.IsSuccessStatusCode is false) + { + return Result.Failure(UserErrors.UserDoesNotExist); + } + + var result = JsonSerializer.Deserialize(content, serializerOptions); + if (result is null) + { + return Result.Failure(UserErrors.UserDoesNotExist); + } + + return Result.Success(result); + } +} diff --git a/UIs/Merchants/Source/Authentication/SessionManager.cs b/UIs/Merchants/Source/Authentication/SessionManager.cs new file mode 100644 index 0000000..531d4a5 --- /dev/null +++ b/UIs/Merchants/Source/Authentication/SessionManager.cs @@ -0,0 +1,28 @@ +namespace Comanda.Merchants.WebUI.Authentication; + +public sealed class SessionManager(ILocalStorageGateway localStorage, IIdentityClient identityClient, AuthenticationStateProvider provider) : ISessionManager +{ + public async Task SignInAsync(string token, string refreshToken) + { + await localStorage.SetAsStringAsync(Storage.SecurityToken, token); + await localStorage.SetAsStringAsync(Storage.RefreshToken, refreshToken); + + // inform provider of signin because blazor does not automatically detect localstorage updates + if (provider is JwtAuthenticationStateProvider authenticationState) + authenticationState.NotifyAuthenticationStateChanged(); + } + + public async Task SignOutAsync() + { + var refreshToken = await localStorage.GetAsStringAsync(Storage.RefreshToken); + if (string.IsNullOrWhiteSpace(refreshToken)) + return; + + await localStorage.RemoveAsync(Storage.SecurityToken); + await identityClient.InvalidateSessionAsync(new() { RefreshToken = refreshToken }); + + // notify provider of logout because blazor does not detect localstorage changes automatically + if (provider is JwtAuthenticationStateProvider authenticationState) + authenticationState.NotifyAuthenticationStateChanged(); + } +} diff --git a/UIs/Merchants/Source/Comanda.Merchants.WebUI.csproj b/UIs/Merchants/Source/Comanda.Merchants.WebUI.csproj new file mode 100644 index 0000000..7e3d477 --- /dev/null +++ b/UIs/Merchants/Source/Comanda.Merchants.WebUI.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/UIs/Merchants/Source/Components/AnalyticsView.razor b/UIs/Merchants/Source/Components/AnalyticsView.razor new file mode 100644 index 0000000..c988663 --- /dev/null +++ b/UIs/Merchants/Source/Components/AnalyticsView.razor @@ -0,0 +1,168 @@ + + + + + + + + + + + Vendas Hoje + + + R$ 2.847,50 + + + + + + + + + + + + + Pedidos Hoje + + + 42 + + + + + + + + + + + + + Pedidos Pendentes + + + 7 + + + + + Aguardando + + + + + + + + + + + + + + Ticket Médio + + + R$ 67,80 + + + + + + + + + + Resumo dos Últimos 7 Dias + + + Atualizado @DateTime.Now.ToString("HH:mm") + + + + Atualizar + + + + + + + + + + Total Pedidos + + + 287 + + + + + + + + + + + Receita Total + + + R$ 19.458,90 + + + + + + + + + + + Média Diária + + + R$ 2.779,84 + + + + + + + + + + + Crescimento + + + +15.8% + + + + + + + \ No newline at end of file diff --git a/UIs/Merchants/Source/Components/Dialogs/ProductCreationDialog.razor b/UIs/Merchants/Source/Components/Dialogs/ProductCreationDialog.razor new file mode 100644 index 0000000..a2be2e4 --- /dev/null +++ b/UIs/Merchants/Source/Components/Dialogs/ProductCreationDialog.razor @@ -0,0 +1,20 @@ + + + + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + private void HandleSuccess() + { + MudDialog.Close(DialogResult.Ok(true)); + } + + private void HandleCancel() + { + MudDialog.Cancel(); + } +} diff --git a/UIs/Merchants/Source/Components/Dialogs/ProductUpdateDialog.razor b/UIs/Merchants/Source/Components/Dialogs/ProductUpdateDialog.razor new file mode 100644 index 0000000..05f5fcd --- /dev/null +++ b/UIs/Merchants/Source/Components/Dialogs/ProductUpdateDialog.razor @@ -0,0 +1,28 @@ +@inject IDialogService DialogService + + + + + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public ProductScheme? Product { get; set; } + + [Parameter] + public string ProductId { get; set; } = string.Empty; + + private void HandleSuccess() + { + MudDialog.Close(DialogResult.Ok(true)); + } + + private void HandleCancel() + { + MudDialog.Cancel(); + } +} diff --git a/UIs/Merchants/Source/Components/KitchenBoard.razor b/UIs/Merchants/Source/Components/KitchenBoard.razor new file mode 100644 index 0000000..0c5ba20 --- /dev/null +++ b/UIs/Merchants/Source/Components/KitchenBoard.razor @@ -0,0 +1,164 @@ + + + + + + + + + + + + Confirmado + + + + @_allOrders.Count(x => x.Status == "Confirmed") + + + + + + + + + + + + + + + Em Preparação + + + + @_allOrders.Count(x => x.Status == "InPreparation") + + + + + + + + + + + + + + + Pronto + + + + @_allOrders.Count(x => x.Status == "Ready") + + + + + + + + + + + + + + + Finalizado + + + + @_allOrders.Count(x => x.Status == "Finalized") + + + + + + + + + + + + + + + Pedido #@context.Id + + + @context.Time + + + + @context.Items + + + + Mesa @context.Table + + @if (context.Status == "Finalized") + { + + Entregue + + } + + + + + + + +@code { + private List _allOrders = new() +{ +new KitchenOrder { Id = "001", Time = "10:15", Items = "2x Hambúrguer, 1x Batata Frita", Table = "5", Status = +"Confirmed" }, +new KitchenOrder { Id = "002", Time = "10:18", Items = "1x Pizza Margherita", Table = "12", Status = "Confirmed" }, +new KitchenOrder { Id = "003", Time = "10:22", Items = "3x Refrigerante, 2x Pastel", Table = "7", Status = "Confirmed" }, +new KitchenOrder { Id = "004", Time = "10:05", Items = "1x X-Bacon, 1x Suco", Table = "3", Status = "InPreparation" }, +new KitchenOrder { Id = "005", Time = "10:08", Items = "2x Porção de Fritas", Table = "9", Status = "InPreparation" }, +new KitchenOrder { Id = "006", Time = "09:55", Items = "1x Misto Quente, 1x Café", Table = "2", Status = "Ready" }, +new KitchenOrder { Id = "007", Time = "09:45", Items = "2x AçaÃ, 1x Tapioca", Table = "8", Status = "Finalized" } +}; + + private void OnItemDropped(MudItemDropInfo dropItem) + { + if (dropItem.Item == null) + return; + + dropItem.Item.Status = dropItem.DropzoneIdentifier; + StateHasChanged(); + } + + private class KitchenOrder + { + public string Id { get; set; } = string.Empty; + public string Time { get; set; } = string.Empty; + public string Items { get; set; } = string.Empty; + public string Table { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Components/PaginatedView.razor b/UIs/Merchants/Source/Components/PaginatedView.razor new file mode 100644 index 0000000..300793d --- /dev/null +++ b/UIs/Merchants/Source/Components/PaginatedView.razor @@ -0,0 +1,65 @@ +@typeparam TItem + + + @if (Data?.Items is not null && Data.Items.Any()) + { + @Content(Data.Items) + + @if (Data.TotalPages > 1) + { + + + + + + + Mostrando @GetStartItem() até @GetEndItem() de @Data.Total itens + + + } + } + else + { + @EmptyContent + } + + +@code { + [Parameter] + public PaginationScheme? Data { get; set; } + + [Parameter] + public RenderFragment> Content { get; set; } = null!; + + [Parameter] + public RenderFragment EmptyContent { get; set; } = null!; + + [Parameter] + public EventCallback OnPageChanged { get; set; } + + private async Task HandlePageChanged(int pageNumber) + { + await OnPageChanged.InvokeAsync(pageNumber - 1); + } + + private int GetStartItem() + { + if (Data is null || Data.PageNumber < 1) return 0; + return ((Data.PageNumber - 1) * Data.PageSize) + 1; + } + + private int GetEndItem() + { + if (Data is null) return 0; + return Math.Min(Data.PageNumber * Data.PageSize, Data.Total); + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Components/ProductCard.razor b/UIs/Merchants/Source/Components/ProductCard.razor new file mode 100644 index 0000000..2957610 --- /dev/null +++ b/UIs/Merchants/Source/Components/ProductCard.razor @@ -0,0 +1,75 @@ + + + + + + + + + + + @Name + + + + + Preço + + + @Price.ToString("C2") + + + + + + @Description + + + + + + + @ProductId + + + + + Editar + + + Excluir + + + + + + + + +@code { + [Parameter] + public string ProductId { get; set; } = string.Empty; + + [Parameter] + public string Name { get; set; } = string.Empty; + + [Parameter] + public string Description { get; set; } = string.Empty; + + [Parameter] + public decimal Price { get; set; } + + [Parameter] + public string ImageUrl { get; set; } = string.Empty; + + [Parameter] + public EventCallback OnEdit { get; set; } + + [Parameter] + public EventCallback OnDelete { get; set; } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Components/ProductGallery.razor b/UIs/Merchants/Source/Components/ProductGallery.razor new file mode 100644 index 0000000..fc709a2 --- /dev/null +++ b/UIs/Merchants/Source/Components/ProductGallery.razor @@ -0,0 +1,68 @@ +@inject IConfiguration configuration + + + @foreach (var product in Products) + { + + } + + @if (!Products.Any()) + { + + + + Nenhum produto cadastrado + + + Adicione produtos para começar a vender + + + } + + +@code { + [Parameter] + public List Products { get; set; } = new(); + + [Parameter] + public EventCallback OnEdit { get; set; } + + [Parameter] + public EventCallback OnDelete { get; set; } + + private async Task HandleEdit(string productId) + { + await OnEdit.InvokeAsync(productId); + } + + private async Task HandleDelete(string productId) + { + await OnDelete.InvokeAsync(productId); + } + + private string GetFullImageUrl(string imageUrl) + { + if (string.IsNullOrEmpty(imageUrl)) + return string.Empty; + + var blobUrl = configuration["Settings:Blob"]; + if (string.IsNullOrEmpty(blobUrl)) + return imageUrl; + + blobUrl = blobUrl.TrimEnd('/'); + imageUrl = imageUrl.TrimStart('/'); + + return $"{blobUrl}/{imageUrl}"; + } + + public class ProductModel + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public decimal Price { get; set; } + public string ImageUrl { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Components/ProductSearch.razor b/UIs/Merchants/Source/Components/ProductSearch.razor new file mode 100644 index 0000000..9619c43 --- /dev/null +++ b/UIs/Merchants/Source/Components/ProductSearch.razor @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + Limpar Filtros + + + Buscar + + + + @if (HasActiveFilters) + { + + + + @GetActiveFiltersText() + + + } + + + +@code { + private string? _searchTitle; + private decimal? _minPrice; + private decimal? _maxPrice; + + [Parameter, EditorRequired] + public required ProductsFetchParameters Filters { get; set; } + + [Parameter] + public EventCallback OnFiltersChanged { get; set; } + + private bool HasActiveFilters => !string.IsNullOrWhiteSpace(_searchTitle) || + _minPrice.HasValue || + _maxPrice.HasValue; + + protected override void OnParametersSet() + { + _searchTitle = Filters.Title; + _minPrice = Filters.MinPrice; + _maxPrice = Filters.MaxPrice; + } + + private async Task OnSearchChanged() => await ApplyFilters(); + private async Task ApplyFilters() + { + var updatedFilters = Filters with + { + Title = string.IsNullOrWhiteSpace(_searchTitle) ? null : _searchTitle.Trim(), + MinPrice = _minPrice, + MaxPrice = _maxPrice + }; + + await OnFiltersChanged.InvokeAsync(updatedFilters); + } + + private async Task HandleClearAll() + { + _searchTitle = null; + _minPrice = null; + _maxPrice = null; + + await ApplyFilters(); + } + + private string GetActiveFiltersText() + { + var filters = new List(); + + if (!string.IsNullOrWhiteSpace(_searchTitle)) + filters.Add($"TÃtulo: \"{_searchTitle}\""); + + if (_minPrice.HasValue) + filters.Add($"Min: {_minPrice:C2}"); + + if (_maxPrice.HasValue) + filters.Add($"Max: {_maxPrice:C2}"); + + return $"Filtros ativos: {string.Join(" | ", filters)}"; + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Components/Support.razor b/UIs/Merchants/Source/Components/Support.razor new file mode 100644 index 0000000..57e4043 --- /dev/null +++ b/UIs/Merchants/Source/Components/Support.razor @@ -0,0 +1,22 @@ +@inject IJSRuntime javascript + + + + + + Falar com suporte + + + +@code { + private async Task OpenWhatsApp() + { + var message = @"Olá, preciso de suporte com o comanda"; + var encodedMessage = Uri.EscapeDataString(message); + var whatsappUrl = $"https://wa.me/5521988547794?text={encodedMessage}"; + + await javascript.InvokeVoidAsync("open", whatsappUrl, "_blank"); + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Constants/AuthenticationDefaults.cs b/UIs/Merchants/Source/Constants/AuthenticationDefaults.cs new file mode 100644 index 0000000..b6dc2a0 --- /dev/null +++ b/UIs/Merchants/Source/Constants/AuthenticationDefaults.cs @@ -0,0 +1,7 @@ +namespace Comanda.Merchants.WebUI.Constants; + +public static class AuthenticationDefaults +{ + public const string Type = "Jwt"; + public const string Scheme = "Bearer"; +} diff --git a/UIs/Merchants/Source/Constants/Headers.cs b/UIs/Merchants/Source/Constants/Headers.cs new file mode 100644 index 0000000..1fd16d0 --- /dev/null +++ b/UIs/Merchants/Source/Constants/Headers.cs @@ -0,0 +1,6 @@ +namespace Comanda.Merchants.WebUI.Constants; + +public static class Headers +{ + public const string Realm = "Realm"; +} diff --git a/UIs/Merchants/Source/Constants/SidebarLabels.cs b/UIs/Merchants/Source/Constants/SidebarLabels.cs new file mode 100644 index 0000000..494f2b7 --- /dev/null +++ b/UIs/Merchants/Source/Constants/SidebarLabels.cs @@ -0,0 +1,10 @@ +namespace Comanda.Merchants.WebUI.Constants; + +public static class SidebarLabels +{ + public const string Orders = "Pedidos"; + public const string Payments = "Pagamentos"; + public const string Products = "Produtos"; + public const string Settings = "Configurações"; + public const string Kitchen = "Cozinha"; +} diff --git a/UIs/Merchants/Source/Constants/Storage.cs b/UIs/Merchants/Source/Constants/Storage.cs new file mode 100644 index 0000000..9f6cfd3 --- /dev/null +++ b/UIs/Merchants/Source/Constants/Storage.cs @@ -0,0 +1,10 @@ +namespace Comanda.Merchants.WebUI.Constants; + +public static class Storage +{ + public const string SecurityToken = "comanda:security-token"; + public const string RefreshToken = "comanda:refresh-token"; + public const string Merchant = "comanda:merchant"; + public const string Establishment = "comanda:establishment"; + public const string Principal = "comanda:principal"; +} diff --git a/UIs/Merchants/Source/Contracts/ILocalStorageGateway.cs b/UIs/Merchants/Source/Contracts/ILocalStorageGateway.cs new file mode 100644 index 0000000..ba59d71 --- /dev/null +++ b/UIs/Merchants/Source/Contracts/ILocalStorageGateway.cs @@ -0,0 +1,13 @@ +namespace Comanda.Merchants.WebUI.Contracts; + +public interface ILocalStorageGateway +{ + public Task GetAsync(string key, CancellationToken cancellation = default); + public Task GetAsStringAsync(string key, CancellationToken cancellation = default); + + public Task SetAsync(string key, T value, CancellationToken cancellation = default); + public Task SetAsStringAsync(string key, string value, CancellationToken cancellation = default); + + public Task RemoveAsync(string key, CancellationToken cancellation = default); + public Task ClearAsync(CancellationToken cancellation = default); +} diff --git a/UIs/Merchants/Source/Contracts/IPrincipalProvider.cs b/UIs/Merchants/Source/Contracts/IPrincipalProvider.cs new file mode 100644 index 0000000..643c5e9 --- /dev/null +++ b/UIs/Merchants/Source/Contracts/IPrincipalProvider.cs @@ -0,0 +1,6 @@ +namespace Comanda.Merchants.WebUI.Contracts; + +public interface IPrincipalProvider +{ + public Task> GetPrincipalAsync(CancellationToken cancellation = default); +} diff --git a/UIs/Merchants/Source/Contracts/ISessionManager.cs b/UIs/Merchants/Source/Contracts/ISessionManager.cs new file mode 100644 index 0000000..a4adc67 --- /dev/null +++ b/UIs/Merchants/Source/Contracts/ISessionManager.cs @@ -0,0 +1,7 @@ +namespace Comanda.Merchants.WebUI.Contracts; + +public interface ISessionManager +{ + Task SignInAsync(string token, string refreshToken); + Task SignOutAsync(); +} diff --git a/UIs/Merchants/Source/Extensions/AuthenticationExtension.cs b/UIs/Merchants/Source/Extensions/AuthenticationExtension.cs new file mode 100644 index 0000000..59cda1b --- /dev/null +++ b/UIs/Merchants/Source/Extensions/AuthenticationExtension.cs @@ -0,0 +1,14 @@ +namespace Comanda.Merchants.WebUI.Extensions; + +public static class AuthenticationExtension +{ + public static void AddAuthentication(this IServiceCollection services) + { + services.AddAuthorizationCore(); + services.AddCascadingAuthenticationState(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/UIs/Merchants/Source/Extensions/ClientExtension.cs b/UIs/Merchants/Source/Extensions/ClientExtension.cs new file mode 100644 index 0000000..410ceb0 --- /dev/null +++ b/UIs/Merchants/Source/Extensions/ClientExtension.cs @@ -0,0 +1,33 @@ +namespace Comanda.Merchants.WebUI.Extensions; + +public static class ClientExtension +{ + public static void AddClients(this IServiceCollection services, IConfiguration configuration) + { + var address = configuration.GetValue("Settings:Gateway")!; + var storeClient = services.AddHttpClient(client => + { + client.BaseAddress = new Uri(address); + client.Timeout = TimeSpan.FromMinutes(minutes: 1, seconds: 30); + }); + + var orderClient = services.AddHttpClient(client => + { + client.BaseAddress = new Uri(address); + client.Timeout = TimeSpan.FromMinutes(minutes: 1, seconds: 30); + }); + + var profilesClient = services.AddHttpClient(client => + { + client.BaseAddress = new Uri(address); + client.Timeout = TimeSpan.FromMinutes(minutes: 1, seconds: 30); + }); + + // ensure an authentication interceptor is always registered for http clients + // every http client must include a message handler responsible for authentication + + storeClient.AddHttpMessageHandler(); + orderClient.AddHttpMessageHandler(); + profilesClient.AddHttpMessageHandler(); + } +} diff --git a/UIs/Merchants/Source/Extensions/GatewaysExtension.cs b/UIs/Merchants/Source/Extensions/GatewaysExtension.cs new file mode 100644 index 0000000..cdb8a11 --- /dev/null +++ b/UIs/Merchants/Source/Extensions/GatewaysExtension.cs @@ -0,0 +1,30 @@ +namespace Comanda.Merchants.WebUI.Extensions; + +public static class GatewaysExtension +{ + public static void AddGateways(this IServiceCollection services, IConfiguration configuration) + { + var address = configuration.GetValue("Settings:IdentityProvider")!; + var realm = configuration.GetValue("Settings:Realm")!; + + services.AddScoped(); + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(address); + client.DefaultRequestHeaders.Add(Headers.Realm, realm); + client.Timeout = TimeSpan.FromMinutes(minutes: 1, seconds: 30); + }); + + var principalGateway = services.AddHttpClient(client => + { + client.BaseAddress = new Uri(address); + client.DefaultRequestHeaders.Add(Headers.Realm, realm); + client.Timeout = TimeSpan.FromMinutes(minutes: 1, seconds: 30); + }); + + // the authentication interceptor will add the access token to each request + // register always after the http client registration + + principalGateway.AddHttpMessageHandler(); + } +} diff --git a/UIs/Merchants/Source/Extensions/ServicesExtension.cs b/UIs/Merchants/Source/Extensions/ServicesExtension.cs new file mode 100644 index 0000000..a782fea --- /dev/null +++ b/UIs/Merchants/Source/Extensions/ServicesExtension.cs @@ -0,0 +1,11 @@ +namespace Comanda.Merchants.WebUI.Extensions; + +public static class ServicesExtension +{ + public static void AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddClients(configuration); + services.AddGateways(configuration); + services.AddAuthentication(); + } +} diff --git a/UIs/Merchants/Source/Forms/EstablishmentUpdateForm.razor b/UIs/Merchants/Source/Forms/EstablishmentUpdateForm.razor new file mode 100644 index 0000000..7b0bdd0 --- /dev/null +++ b/UIs/Merchants/Source/Forms/EstablishmentUpdateForm.razor @@ -0,0 +1,195 @@ +@inject IStoreClient storeClient +@inject ILocalStorageGateway localStorage +@inject ISnackbar snackbar + + + + + + + Informações do Estabelecimento + + + Configure o nome e descrição que aparecerão para seus clientes + + + + + + + + + + + + + + + + Identidade Visual + + + Personalize as cores do seu estabelecimento + + + + + + + + @if (_showPrimaryColorPicker) + { + + } + + @if (!string.IsNullOrEmpty(_establishment.PrimaryColor)) + { + + + Preview da Cor Primária + + + } + + + + + + + + @if (_showSecondaryColorPicker) + { + + } + + @if (!string.IsNullOrEmpty(_establishment.SecondaryColor)) + { + + + Preview da Cor Secundária + + + } + + + + + + + + + Cancelar + + + @if (_isSubmitting) + { + + Salvando... + } + else + { + Salvar Alterações + } + + + + + +@code { + private EstablishmentUpdateFormScheme _establishment = new(); + private EstablishmentScheme? _currentEstablishment; + + private bool _isSubmitting = false; + private bool _showPrimaryColorPicker = false; + private bool _showSecondaryColorPicker = false; + + protected override async Task OnInitializedAsync() + { + await LoadEstablishmentDataAsync(); + } + + private async Task LoadEstablishmentDataAsync() + { + _currentEstablishment = await localStorage.GetAsync(Storage.Establishment); + if (_currentEstablishment is not null) + { + _establishment.Title = _currentEstablishment.Title ?? string.Empty; + _establishment.Description = _currentEstablishment.Description ?? string.Empty; + _establishment.PrimaryColor = _currentEstablishment.Branding?.PrimaryColor ?? "#5B21B6"; + _establishment.SecondaryColor = _currentEstablishment.Branding?.SecondaryColor ?? "#EC4899"; + } + } + + private async Task HandleSubmitAsync() + { + if (_currentEstablishment is null) + { + snackbar.Add("Não foi possÃvel identificar o estabelecimento", Severity.Error); + return; + } + + _isSubmitting = true; + + var primaryColor = _establishment.PrimaryColor?.Length == 9 + ? _establishment.PrimaryColor.Substring(0, 7) + : _establishment.PrimaryColor; + + var secondaryColor = _establishment.SecondaryColor?.Length == 9 + ? _establishment.SecondaryColor.Substring(0, 7) + : _establishment.SecondaryColor; + + var modificationScheme = new EstablishmentModificationScheme + { + EstablishmentId = _currentEstablishment.Identifier, + Title = _establishment.Title, + Description = _establishment.Description, + Branding = new Branding(primaryColor ?? "#5B21B6", secondaryColor ?? "#EC4899", "https://placehold.co/600x400.png") + }; + + var result = await storeClient.UpdateEstablishmentAsync(modificationScheme); + if (result.IsFailure || result.Data is null) + { + snackbar.Add($"Erro ao atualizar estabelecimento: {result.Error.Code}", Severity.Error); + _isSubmitting = false; + return; + } + + await localStorage.SetAsync(Storage.Establishment, result.Data); + + _currentEstablishment = result.Data; + _isSubmitting = false; + + snackbar.Add("Estabelecimento atualizado com sucesso!", Severity.Success); + } + + private async Task HandleCancelAsync() + { + await LoadEstablishmentDataAsync(); + + _showPrimaryColorPicker = false; + _showSecondaryColorPicker = false; + + snackbar.Add("Alterações canceladas", Severity.Info); + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Forms/IntegrationCredentialsForm.razor b/UIs/Merchants/Source/Forms/IntegrationCredentialsForm.razor new file mode 100644 index 0000000..2717e85 --- /dev/null +++ b/UIs/Merchants/Source/Forms/IntegrationCredentialsForm.razor @@ -0,0 +1,157 @@ +@inject IStoreClient storeClient +@inject ILocalStorageGateway localStorage +@inject ISnackbar snackbar + + + + + + + Integrações + + + Configure as credenciais dos serviços integrados + + + + + + + + Gateway de Pagamentos - Processamento de Pagamentos + + + + + + + + + + + Huggy - Mensagens WhatsApp + + + + Em Breve + + + + + + + + + + + Cancelar + + + @if (_isSubmitting) + { + + Salvando... + } + else + { + Salvar Alterações + } + + + + + +@code { + private IntegrationCredentialsFormScheme _model = new(); + + private bool _isSubmitting = false; + private string? _establishmentId; + private string? _paymentGatewayCredentialId; + private string? _whatsappCredentialId; + + protected override async Task OnInitializedAsync() + { + await LoadCredentialsAsync(); + } + + private async Task LoadCredentialsAsync() + { + var establishment = await localStorage.GetAsync(Storage.Establishment); + if (establishment is null) + { + snackbar.Add("Estabelecimento não encontrado", Severity.Warning); + return; + } + + _establishmentId = establishment.Identifier; + + var result = await storeClient.GetCredentialsAsync(new() { EstablishmentId = _establishmentId }); + if (result.IsFailure || result.Data is null) + { + return; + } + + foreach (var credential in result.Data) + { + if (credential.Provider == "PaymentGateway" || credential.Provider == IntegrationTarget.PaymentGateway.ToString()) + { + _model.PaymentGatewaySecretKey = credential.SecretKey ?? string.Empty; + _paymentGatewayCredentialId = credential.Identifier; + } + else if (credential.Provider == "Whatsapp" || credential.Provider == IntegrationTarget.Whatsapp.ToString()) + { + _model.WhatsappToken = credential.SecretKey ?? string.Empty; + _whatsappCredentialId = credential.Identifier; + } + } + } + private async Task HandleSubmitAsync() + { + if (string.IsNullOrEmpty(_establishmentId)) + { + snackbar.Add("Estabelecimento não identificado", Severity.Error); + return; + } + + _isSubmitting = true; + + if (!string.IsNullOrEmpty(_model.PaymentGatewaySecretKey)) + { + var paymentCredential = new CredentialCreationScheme + { + EstablishmentId = _establishmentId, + SecretKey = _model.PaymentGatewaySecretKey, + Provider = IntegrationTarget.PaymentGateway + }; + + var paymentResult = await storeClient.AssignIntegrationCredentialAsync(paymentCredential); + if (paymentResult.IsFailure) + { + snackbar.Add($"Erro ao salvar credencial de pagamento: {paymentResult.Error.Code}", Severity.Error); + _isSubmitting = false; + + return; + } + } + + snackbar.Add("Credenciais salvas com sucesso!", Severity.Success); + _isSubmitting = false; + + } + + private async Task HandleCancelAsync() + { + await LoadCredentialsAsync(); + snackbar.Add("Alterações canceladas", Severity.Info); + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Forms/OnboardingForm.razor b/UIs/Merchants/Source/Forms/OnboardingForm.razor new file mode 100644 index 0000000..36e9631 --- /dev/null +++ b/UIs/Merchants/Source/Forms/OnboardingForm.razor @@ -0,0 +1,120 @@ +@inject IStoreClient storeClient +@inject IProfilesClient profilesClient +@inject ILocalStorageGateway localStorage +@inject AuthenticationStateProvider authenticationStateProvider +@inject NavigationManager navigationManager +@inject ISnackbar snackbar + + + + + + + Informações do Estabelecimento + + + Cadastre seu estabelecimento para começar a usar o sistema + + + + + + + + + + + + + + + + + + @if (_isSubmitting) + { + + Criando... + } + else + { + Criar Estabelecimento + } + + + + + +@code { + private EstablishmentFormScheme _model = new(); + private bool _isSubmitting = false; + + private async Task HandleSubmitAsync() + { + var authentication = await authenticationStateProvider.GetAuthenticationStateAsync(); + var principal = authentication.User; + + if (principal?.Identity?.IsAuthenticated is not true) + { + snackbar.Add("Você precisa estar autenticado para criar um estabelecimento", Severity.Error); + return; + } + + var identifier = principal.FindFirst("sub")?.Value; + if (string.IsNullOrEmpty(identifier)) + { + snackbar.Add("Erro ao identificar o usuário", Severity.Error); + return; + } + + var ownersResponse = await profilesClient.GetOwnersAsync(new() { UserId = identifier }); + if (ownersResponse?.Data?.Items is null || !ownersResponse.Data.Items.Any()) + { + snackbar.Add("Erro ao buscar informações do proprietário", Severity.Error); + return; + } + + var owner = ownersResponse.Data.Items.First(); + var scheme = new EstablishmentCreationScheme + { + Title = _model.Title, + Description = _model.Description, + Owner = new(owner.Identifier, owner.Email) + }; + + var result = await storeClient.CreateEstablishmentAsync(scheme); + if (result.IsFailure || result.Data is null) + { + snackbar.Add($"Erro ao criar estabelecimento ({result.Error.Code})", Severity.Error); + return; + } + + await localStorage.SetAsync(Storage.Establishment, result.Data); + await localStorage.SetAsync(Storage.Merchant, owner); + + snackbar.Add("Estabelecimento criado com sucesso!", Severity.Success); + navigationManager.NavigateTo("/"); + + _isSubmitting = false; + } +} diff --git a/UIs/Merchants/Source/Forms/ProductCreationForm.razor b/UIs/Merchants/Source/Forms/ProductCreationForm.razor new file mode 100644 index 0000000..45cd6a3 --- /dev/null +++ b/UIs/Merchants/Source/Forms/ProductCreationForm.razor @@ -0,0 +1,172 @@ +@inject IStoreClient storeClient +@inject ILocalStorageGateway localStorage +@inject ISnackbar snackbar + + + + + + + + + + + + @if (_imagePreview is not null) + { + + + + + + + + Imagem selecionada + + + @_selectedFileName + + + + + + } + else + { + + + + + Adicionar imagem + + + + + } + + + + + + Cancelar + + + @if (_isSubmitting) + { + + Salvando... + } + else + { + + Salvar + } + + + + + +@code { + [Parameter] + public EventCallback OnSuccess { get; set; } + + [Parameter] + public EventCallback OnCancel { get; set; } + + private ProductCreationFormScheme _model = new(); + private bool _isSubmitting = false; + private byte[]? _imageBytes; + private string? _imagePreview; + private string? _selectedFileName; + private string? _imageContentType; + + private async Task HandleFileSelected(IBrowserFile? file) + { + if (file is null) + return; + + _selectedFileName = file.Name; + _imageContentType = file.ContentType; + + var buffer = new byte[file.Size]; + using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); + + await stream.ReadAsync(buffer); + + _imageBytes = buffer; + _imagePreview = $"data:{file.ContentType};base64,{Convert.ToBase64String(buffer)}"; + } + + private void ClearImage() + { + _imageBytes = null; + _imagePreview = null; + _selectedFileName = null; + _imageContentType = null; + } + + private async Task HandleSubmitAsync() + { + _isSubmitting = true; + + var establishment = await localStorage.GetAsync(Storage.Establishment); + if (establishment is null) + { + snackbar.Add("Erro ao identificar o estabelecimento", Severity.Error); + return; + } + + var creationScheme = new ProductCreationScheme + { + EstablishmentId = establishment.Identifier, + Title = _model.Title, + Description = _model.Description, + Price = _model.Price + }; + + var result = await storeClient.CreateProductAsync(creationScheme); + if (!result.IsSuccess || result.Data is null) + { + snackbar.Add($"Erro ao criar produto ({result.Error.Code})", Severity.Error); + return; + } + + if (_imageBytes is not null) + { + using var memoryStream = new MemoryStream(_imageBytes); + var imageScheme = new ProductImageStreamScheme + { + EstablishmentId = establishment.Identifier, + ProductId = result.Data.Identifier, + Stream = memoryStream + }; + + var imageResult = await storeClient.UploadProductImage(imageScheme); + if (!imageResult.IsSuccess) + { + snackbar.Add($"Produto criado, mas houve erro ao fazer upload da imagem ({imageResult.Error.Code})", Severity.Warning); + } + } + + snackbar.Add("Produto criado com sucesso!", Severity.Success); + + _model = new(); + ClearImage(); + await OnSuccess.InvokeAsync(); + + _isSubmitting = false; + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Forms/ProductUpdateForm.razor b/UIs/Merchants/Source/Forms/ProductUpdateForm.razor new file mode 100644 index 0000000..fd1d2de --- /dev/null +++ b/UIs/Merchants/Source/Forms/ProductUpdateForm.razor @@ -0,0 +1,110 @@ +@inject IStoreClient storeClient +@inject ILocalStorageGateway localStorage +@inject ISnackbar snackbar + + + + + + + + + + + + + + + + Cancelar + + + @if (_isSubmitting) + { + + Salvando... + } + else + { + + Salvar + } + + + + + +@code { + [Parameter] + public string ProductId { get; set; } = string.Empty; + + [Parameter] + public EventCallback OnSuccess { get; set; } + + [Parameter] + public EventCallback OnCancel { get; set; } + + private ProductUpdateFormScheme _model = new(); + private bool _isSubmitting = false; + + protected override void OnParametersSet() + { + if (Product is not null) + { + _model = new ProductUpdateFormScheme + { + Title = Product.Title, + Description = Product.Description, + Price = Product.Price + }; + } + } + + [Parameter] + public ProductScheme? Product { get; set; } + + private async Task HandleSubmitAsync() + { + _isSubmitting = true; + + var establishment = await localStorage.GetAsync(Storage.Establishment); + if (establishment is null) + { + snackbar.Add("Erro ao identificar o estabelecimento", Severity.Error); + _isSubmitting = false; + return; + } + + var modificationScheme = new ProductModificationScheme + { + EstablishmentId = establishment.Identifier, + ProductId = ProductId, + Title = _model.Title, + Description = _model.Description, + Price = _model.Price + }; + + var result = await storeClient.UpdateProductAsync(modificationScheme); + if (!result.IsSuccess || result.Data is null) + { + snackbar.Add($"Erro ao atualizar produto ({result.Error.Code})", Severity.Error); + _isSubmitting = false; + return; + } + + snackbar.Add("Produto atualizado com sucesso!", Severity.Success); + + await OnSuccess.InvokeAsync(); + + _isSubmitting = false; + } +} diff --git a/UIs/Merchants/Source/Forms/Schemes/EstablishmentFormScheme.cs b/UIs/Merchants/Source/Forms/Schemes/EstablishmentFormScheme.cs new file mode 100644 index 0000000..3082950 --- /dev/null +++ b/UIs/Merchants/Source/Forms/Schemes/EstablishmentFormScheme.cs @@ -0,0 +1,12 @@ +namespace Comanda.Merchants.WebUI.Forms.Schemes; + +public sealed record EstablishmentFormScheme +{ + [Required(ErrorMessage = "O nome do estabelecimento é obrigatório")] + [StringLength(100, MinimumLength = 3, ErrorMessage = "O nome deve ter entre 3 e 100 caracteres")] + public string Title { get; set; } = string.Empty; + + [Required(ErrorMessage = "A descrição é obrigatória")] + [StringLength(500, MinimumLength = 10, ErrorMessage = "A descrição deve ter entre 10 e 500 caracteres")] + public string Description { get; set; } = string.Empty; +} diff --git a/UIs/Merchants/Source/Forms/Schemes/EstablishmentUpdateFormScheme.cs b/UIs/Merchants/Source/Forms/Schemes/EstablishmentUpdateFormScheme.cs new file mode 100644 index 0000000..7a7d05c --- /dev/null +++ b/UIs/Merchants/Source/Forms/Schemes/EstablishmentUpdateFormScheme.cs @@ -0,0 +1,19 @@ +namespace Comanda.Merchants.WebUI.Forms.Schemes; + +public sealed record EstablishmentUpdateFormScheme +{ + [Required(ErrorMessage = "O nome do estabelecimento é obrigatório")] + [StringLength(100, MinimumLength = 3, ErrorMessage = "O nome deve ter entre 3 e 100 caracteres")] + public string Title { get; set; } = string.Empty; + + [Required(ErrorMessage = "A descrição é obrigatória")] + [StringLength(500, MinimumLength = 10, ErrorMessage = "A descrição deve ter entre 10 e 500 caracteres")] + public string Description { get; set; } = string.Empty; + + [RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{8})$", ErrorMessage = "Cor primária deve ser um código hexadecimal válido (ex: #4a1998)")] + public string PrimaryColor { get; set; } = "#5B21B6"; + + [RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{8})$", ErrorMessage = "Cor secundária deve ser um código hexadecimal válido (ex: #ec4899)")] + public string SecondaryColor { get; set; } = "#EC4899"; + public string Logo { get; set; } = string.Empty; +} diff --git a/UIs/Merchants/Source/Forms/Schemes/IntegrationCredentialsFormScheme.cs b/UIs/Merchants/Source/Forms/Schemes/IntegrationCredentialsFormScheme.cs new file mode 100644 index 0000000..c03b2cb --- /dev/null +++ b/UIs/Merchants/Source/Forms/Schemes/IntegrationCredentialsFormScheme.cs @@ -0,0 +1,9 @@ +namespace Comanda.Merchants.WebUI.Forms.Schemes; + +public sealed record IntegrationCredentialsFormScheme +{ + [Required(ErrorMessage = "A chave secreta do gateway de pagamentos é obrigatória")] + [StringLength(500, MinimumLength = 10, ErrorMessage = "A chave secreta deve ter entre 10 e 500 caracteres")] + public string PaymentGatewaySecretKey { get; set; } = string.Empty; + public string WhatsappToken { get; set; } = string.Empty; +} diff --git a/UIs/Merchants/Source/Forms/Schemes/ProductCreationFormScheme.cs b/UIs/Merchants/Source/Forms/Schemes/ProductCreationFormScheme.cs new file mode 100644 index 0000000..fb6b13b --- /dev/null +++ b/UIs/Merchants/Source/Forms/Schemes/ProductCreationFormScheme.cs @@ -0,0 +1,16 @@ +namespace Comanda.Merchants.WebUI.Forms.Schemes; + +public sealed record ProductCreationFormScheme +{ + [Required(ErrorMessage = "O tÃtulo do produto é obrigatório")] + [StringLength(100, MinimumLength = 3, ErrorMessage = "O tÃtulo deve ter entre 3 e 100 caracteres")] + public string Title { get; set; } = string.Empty; + + [Required(ErrorMessage = "A descrição é obrigatória")] + [StringLength(500, MinimumLength = 10, ErrorMessage = "A descrição deve ter entre 10 e 500 caracteres")] + public string Description { get; set; } = string.Empty; + + [Required(ErrorMessage = "O preço é obrigatório")] + [Range(0.01, 999999.99, ErrorMessage = "O preço deve ser maior que zero")] + public decimal Price { get; set; } +} diff --git a/UIs/Merchants/Source/Forms/Schemes/ProductUpdateFormScheme.cs b/UIs/Merchants/Source/Forms/Schemes/ProductUpdateFormScheme.cs new file mode 100644 index 0000000..b5e31a5 --- /dev/null +++ b/UIs/Merchants/Source/Forms/Schemes/ProductUpdateFormScheme.cs @@ -0,0 +1,16 @@ +namespace Comanda.Merchants.WebUI.Forms.Schemes; + +public sealed record ProductUpdateFormScheme +{ + [Required(ErrorMessage = "O tÃtulo do produto é obrigatório")] + [StringLength(100, MinimumLength = 3, ErrorMessage = "O tÃtulo deve ter entre 3 e 100 caracteres")] + public string Title { get; set; } = string.Empty; + + [Required(ErrorMessage = "A descrição é obrigatória")] + [StringLength(500, MinimumLength = 10, ErrorMessage = "A descrição deve ter entre 10 e 500 caracteres")] + public string Description { get; set; } = string.Empty; + + [Required(ErrorMessage = "O preço é obrigatório")] + [Range(0.01, 999999.99, ErrorMessage = "O preço deve ser maior que zero")] + public decimal Price { get; set; } +} diff --git a/UIs/Merchants/Source/Gateways/LocalStorageGateway.cs b/UIs/Merchants/Source/Gateways/LocalStorageGateway.cs new file mode 100644 index 0000000..6da19e2 --- /dev/null +++ b/UIs/Merchants/Source/Gateways/LocalStorageGateway.cs @@ -0,0 +1,38 @@ +namespace Comanda.Merchants.WebUI.Gateways; + +public sealed class LocalStorageGateway(IJSRuntime javascript) : ILocalStorageGateway +{ + public async Task GetAsStringAsync(string key, CancellationToken cancellation = default) + { + return await javascript.InvokeAsync("localStorage.getItem", key); + } + + public async Task GetAsync(string key, CancellationToken cancellation = default) + { + var value = await javascript.InvokeAsync("localStorage.getItem", key); + if (string.IsNullOrEmpty(value)) + return default; + + return JsonSerializer.Deserialize(value); + } + + public async Task SetAsStringAsync(string key, string value, CancellationToken cancellation = default) + { + await javascript.InvokeVoidAsync("localStorage.setItem", key, value); + } + + public async Task SetAsync(string key, T value, CancellationToken cancellation = default) + { + await javascript.InvokeVoidAsync("localStorage.setItem", key, JsonSerializer.Serialize(value)); + } + + public async Task RemoveAsync(string key, CancellationToken cancellation = default) + { + await javascript.InvokeVoidAsync("localStorage.removeItem", key); + } + + public async Task ClearAsync(CancellationToken cancellation = default) + { + await javascript.InvokeVoidAsync("localStorage.clear"); + } +} diff --git a/UIs/Merchants/Source/Http/Clients/IdentityClient.cs b/UIs/Merchants/Source/Http/Clients/IdentityClient.cs new file mode 100644 index 0000000..202186c --- /dev/null +++ b/UIs/Merchants/Source/Http/Clients/IdentityClient.cs @@ -0,0 +1,80 @@ +#pragma warning disable S1125 // we prefer explicit boolean comparisons for readability + +namespace Comanda.Merchants.WebUI.Http.Clients; + +public sealed class IdentityClient(HttpClient httpClient) : IIdentityClient +{ + private readonly JsonSerializerOptions serializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public async Task> AuthenticateAsync(AuthenticationCredentials credentials, CancellationToken cancellation = default) + { + var response = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials, cancellation); + if (response.IsSuccessStatusCode is false) + { + var error = await response.Content.ReadFromJsonAsync( + options: serializerOptions, + cancellationToken: cancellation + ); + + return error is not null + ? Result.Failure(error) + : Result.Failure(Error.Unknown); + } + + var result = await response.Content.ReadFromJsonAsync( + options: serializerOptions, + cancellationToken: cancellation + ); + + return result is not null + ? Result.Success(result) + : Result.Failure(Error.Unknown); + } + + public async Task> CreateIdentityAsync(IdentityEnrollmentCredentials credentials, CancellationToken cancellation = default) + { + var response = await httpClient.PostAsJsonAsync("api/v1/identity", credentials, cancellation); + if (response.IsSuccessStatusCode is false) + { + var error = await response.Content.ReadFromJsonAsync( + options: serializerOptions, + cancellationToken: cancellation + ); + + return error is not null + ? Result.Failure(error) + : Result.Failure(Error.Unknown); + } + + var result = await response.Content.ReadFromJsonAsync( + options: serializerOptions, + cancellationToken: cancellation + ); + + return result is not null + ? Result.Success(result) + : Result.Failure(Error.Unknown); + } + + public async Task InvalidateSessionAsync(SessionInvalidation session, CancellationToken cancellation = default) + { + var response = await httpClient.PostAsJsonAsync("api/v1/identity/invalidate-session", session, cancellation); + if (response.IsSuccessStatusCode is false) + { + var error = await response.Content.ReadFromJsonAsync( + options: serializerOptions, + cancellationToken: cancellation + ); + + return error is not null + ? Result.Failure(error) + : Result.Failure(Error.Unknown); + } + + return Result.Success(); + } +} diff --git a/UIs/Merchants/Source/Http/Interceptors/AuthenticationInterceptor.cs b/UIs/Merchants/Source/Http/Interceptors/AuthenticationInterceptor.cs new file mode 100644 index 0000000..5a2dea4 --- /dev/null +++ b/UIs/Merchants/Source/Http/Interceptors/AuthenticationInterceptor.cs @@ -0,0 +1,16 @@ +namespace Comanda.Merchants.WebUI.Http.Interceptors; + +public sealed class AuthenticationInterceptor(ILocalStorageGateway localStorage) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var token = await localStorage.GetAsStringAsync(Storage.SecurityToken, cancellationToken); + + /* https://www.rfc-editor.org/rfc/rfc6750 */ + if (!string.IsNullOrWhiteSpace(token)) + request.Headers.Authorization = new(AuthenticationDefaults.Scheme, token); + + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/UIs/Merchants/Source/Http/Payloads/Identity/PrincipalScheme.cs b/UIs/Merchants/Source/Http/Payloads/Identity/PrincipalScheme.cs new file mode 100644 index 0000000..b35ebe5 --- /dev/null +++ b/UIs/Merchants/Source/Http/Payloads/Identity/PrincipalScheme.cs @@ -0,0 +1,13 @@ +namespace Comanda.Merchants.WebUI.Http.Payloads.Identity; + +public sealed record PrincipalScheme +{ + public string Id { get; init; } = default!; + public string Username { get; init; } = default!; + + public DateTime CreatedAt { get; init; } + public DateTime? UpdatedAt { get; init; } + + public IReadOnlyCollection Permissions { get; init; } = []; + public IReadOnlyCollection Groups { get; init; } = []; +} diff --git a/UIs/Merchants/Source/Layout/MainLayout.razor b/UIs/Merchants/Source/Layout/MainLayout.razor new file mode 100644 index 0000000..2065178 --- /dev/null +++ b/UIs/Merchants/Source/Layout/MainLayout.razor @@ -0,0 +1,31 @@ +@inherits LayoutComponentBase + + + + + + + + + + + + + + + @Body + + + +@code { + private readonly MudTheme _theme = new() + { + PaletteLight = new PaletteLight + { + Primary = "#EE254B", + PrimaryDarken = "#D11F3F", + PrimaryLighten = "#F24D6F", + Secondary = "#EC4899", + } + }; +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Layout/NavigationBar.razor b/UIs/Merchants/Source/Layout/NavigationBar.razor new file mode 100644 index 0000000..f3aba3a --- /dev/null +++ b/UIs/Merchants/Source/Layout/NavigationBar.razor @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/UIs/Merchants/Source/Layout/Sidebar.razor b/UIs/Merchants/Source/Layout/Sidebar.razor new file mode 100644 index 0000000..26edb47 --- /dev/null +++ b/UIs/Merchants/Source/Layout/Sidebar.razor @@ -0,0 +1,57 @@ + + + + + Dashboard + + + + + + @SidebarLabels.Orders + + + + + + @SidebarLabels.Products + + + + + + @SidebarLabels.Kitchen + + + + + + @SidebarLabels.Payments + + + + + + @SidebarLabels.Settings + + + + + + + + +@code { + [Parameter] + public bool IsOpen { get; set; } + + [Parameter] + public EventCallback IsOpenChanged { get; set; } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Pages/Home.razor b/UIs/Merchants/Source/Pages/Home.razor new file mode 100644 index 0000000..47920b1 --- /dev/null +++ b/UIs/Merchants/Source/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +Dashboard + + + + \ No newline at end of file diff --git a/UIs/Merchants/Source/Pages/Kitchen.razor b/UIs/Merchants/Source/Pages/Kitchen.razor new file mode 100644 index 0000000..35c8d8d --- /dev/null +++ b/UIs/Merchants/Source/Pages/Kitchen.razor @@ -0,0 +1,33 @@ +@page "/cozinha" + +Cozinha + + + + + + + + + + + Cozinha + + + Gerencie o fluxo de preparação dos pedidos + + + + + + + + + + + diff --git a/UIs/Merchants/Source/Pages/NotAuthorized.razor b/UIs/Merchants/Source/Pages/NotAuthorized.razor new file mode 100644 index 0000000..896383b --- /dev/null +++ b/UIs/Merchants/Source/Pages/NotAuthorized.razor @@ -0,0 +1,28 @@ +@page "/not-authorized" +@layout MainLayout + +Não Autorizado + + + + 403 + + + + Você não está autorizado + + + + Você não possui permissão para acessar esta página. Entre em contato com o administrador se acredita que isso é um erro. + + + + Voltar para o inÃcio + + diff --git a/UIs/Merchants/Source/Pages/NotFound.razor b/UIs/Merchants/Source/Pages/NotFound.razor new file mode 100644 index 0000000..8822e79 --- /dev/null +++ b/UIs/Merchants/Source/Pages/NotFound.razor @@ -0,0 +1,28 @@ +@page "/not-found" +@layout MainLayout + +Não Encontrado + + + + 404 + + + + Está página não existe + + + + A página que você está procurando pode ter sido removida, teve seu nome alterado ou está temporariamente indisponÃvel. + + + + Voltar para o inÃcio + + \ No newline at end of file diff --git a/UIs/Merchants/Source/Pages/Onboarding.razor b/UIs/Merchants/Source/Pages/Onboarding.razor new file mode 100644 index 0000000..9e6e6eb --- /dev/null +++ b/UIs/Merchants/Source/Pages/Onboarding.razor @@ -0,0 +1,70 @@ +@page "/onboarding" +@layout MainLayout + +@inject ILocalStorageGateway localStorage +@inject IStoreClient storeClient +@inject NavigationManager navigationManager + +Onboarding + +@if (isLoading) +{ + + + +} +else if (!hasEstablishment) +{ + + + + + + + + + Bem-vindo! + + + Vamos começar criando seu estabelecimento + + + + + + +} + +@code { + private bool hasEstablishment = true; + private bool isLoading = true; + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + isLoading = true; + + var owner = await localStorage.GetAsync(Storage.Merchant); + if (owner is null) + { + isLoading = false; + return; + } + + var establishments = await storeClient.GetEstablishmentsAsync(new() { OwnerId = owner.Identifier }); + var establishment = establishments.Data?.Items.FirstOrDefault(); + + hasEstablishment = establishment is not null; + + if (hasEstablishment) + { + navigationManager.NavigateTo("/"); + return; + } + + isLoading = false; + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Pages/Products.razor b/UIs/Merchants/Source/Pages/Products.razor new file mode 100644 index 0000000..78350b2 --- /dev/null +++ b/UIs/Merchants/Source/Pages/Products.razor @@ -0,0 +1,258 @@ +@page "/produtos" + +@inject IStoreClient storeClient +@inject ILocalStorageGateway localStorage +@inject IDialogService dialogService +@inject ISnackbar snackbar +@inject IConfiguration configuration + +Produtos + + + + + + + + + + + Produtos + + + Gerencie o cardápio do seu estabelecimento + + + + + + + Adicionar Produto + + + + + + + + @if (_isLoading) + { + + + + } + else if (_paginatedProducts is null) + { + Dados de paginação nulos + } + else if (_paginatedProducts.Items is null || !_paginatedProducts.Items.Any()) + { + + + + Nenhum produto cadastrado + + + Adicione produtos para começar a vender + + + } + else + { + + @foreach (var product in _paginatedProducts.Items) + { + + } + + + + + + + + + Mostrando @GetStartItem() até @GetEndItem() de @_paginatedProducts.Total itens + + + } + + +@code { + private PaginationScheme? _paginatedProducts; + private bool _isLoading = true; + private int _currentPage = 0; + private const int PageSize = 20; + private ProductsFetchParameters _currentFilters = new(); + + protected override async Task OnInitializedAsync() + { + await LoadProductsAsync(); + } + + private async Task LoadProductsAsync() + { + _isLoading = true; + StateHasChanged(); + + var establishment = await localStorage.GetAsync(Storage.Establishment); + if (establishment is null) + { + snackbar.Add("Erro ao identificar o estabelecimento", Severity.Error); + return; + } + + var parameters = _currentFilters with + { + EstablishmentId = establishment.Identifier, + Pagination = PaginationFilters.From(_currentPage + 1, PageSize) + }; + + var result = await storeClient.GetProductsAsync(parameters); + if (result.IsSuccess && result.Data is not null) + { + _paginatedProducts = result.Data; + } + else + { + snackbar.Add($"Erro ao carregar produtos ({result.Error.Code})", Severity.Error); + } + + _isLoading = false; + StateHasChanged(); + } + + private async Task HandleFiltersChanged(ProductsFetchParameters filters) + { + _currentFilters = filters; + _currentPage = 0; + + await LoadProductsAsync(); + } + + private async Task HandlePageChanged(int pageNumber) + { + _currentPage = pageNumber - 1; + await LoadProductsAsync(); + } + + private int GetStartItem() + { + if (_paginatedProducts is null || _paginatedProducts.PageNumber < 1) return 0; + return ((_paginatedProducts.PageNumber - 1) * _paginatedProducts.PageSize) + 1; + } + + private int GetEndItem() + { + if (_paginatedProducts is null) return 0; + return Math.Min(_paginatedProducts.PageNumber * _paginatedProducts.PageSize, _paginatedProducts.Total); + } + + private async Task HandleAddProduct() + { + var options = new DialogOptions + { + CloseButton = true, + CloseOnEscapeKey = true, + MaxWidth = MaxWidth.Small, + FullWidth = true, + Position = DialogPosition.Center + }; + + var dialog = await dialogService.ShowAsync("", options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadProductsAsync(); + } + } + + private async Task HandleEditProduct(string productId) + { + var product = _paginatedProducts?.Items?.FirstOrDefault(product => product.Identifier == productId); + if (product is null) + { + snackbar.Add("Produto não encontrado", Severity.Error); + return; + } + + var parameters = new DialogParameters + { + { "ProductId", productId }, + { "Product", product } + }; + + var options = new DialogOptions + { + CloseButton = true, + CloseOnEscapeKey = true, + MaxWidth = MaxWidth.Small, + FullWidth = true, + Position = DialogPosition.Center + }; + + var dialog = await dialogService.ShowAsync("", parameters, options); + var result = await dialog.Result; + + if (result is not null && !result.Canceled) + { + await LoadProductsAsync(); + } + } + + private async Task HandleDeleteProduct(string productId) + { + var confirm = await dialogService.ShowMessageBox( + "Confirmar exclusão", + "Tem certeza que deseja excluir este produto?", + yesText: "Excluir", + cancelText: "Cancelar"); + + var establishment = await localStorage.GetAsync(Storage.Establishment); + if (establishment is null) + { + snackbar.Add("Erro ao identificar o estabelecimento", Severity.Error); + return; + } + + if (confirm == true) + { + var result = await storeClient.DeleteProductAsync(new() { EstablishmentId = establishment.Identifier, ProductId = productId }); + if (result.IsSuccess) + { + snackbar.Add("Produto excluÃdo com sucesso!", Severity.Success); + + await LoadProductsAsync(); + return; + } + + snackbar.Add($"Erro ao excluir produto ({result.Error.Code})", Severity.Error); + } + } + + private string GetFullImageUrl(string imageUrl) + { + if (string.IsNullOrEmpty(imageUrl)) + return string.Empty; + + var blobUrl = configuration["Settings:Blob"]; + if (string.IsNullOrEmpty(blobUrl)) + return imageUrl; + + blobUrl = blobUrl.TrimEnd('/'); + imageUrl = imageUrl.TrimStart('/'); + + return $"{blobUrl}/{imageUrl}"; + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Pages/Settings.razor b/UIs/Merchants/Source/Pages/Settings.razor new file mode 100644 index 0000000..1b73a69 --- /dev/null +++ b/UIs/Merchants/Source/Pages/Settings.razor @@ -0,0 +1,64 @@ +@page "/configuracoes" + +Configurações + + + + + + + + + + Configurações + + + Gerencie as informações e preferências do seu estabelecimento + + + + + + + + + + + + + + + + Zona de Perigo + + + Ações irreversÃveis que afetam sua conta + + + + + + + + + + Cancelar Assinatura + + + Ao cancelar, você perderá acesso a todos os recursos do sistema + + + + Cancelar Assinatura + + + + + +@code { +} \ No newline at end of file diff --git a/UIs/Merchants/Source/Program.cs b/UIs/Merchants/Source/Program.cs new file mode 100644 index 0000000..93a0bf1 --- /dev/null +++ b/UIs/Merchants/Source/Program.cs @@ -0,0 +1,20 @@ +namespace Comanda.Merchants.WebUI; + +internal static class Program +{ + private static async Task Main(string[] args) + { + var builder = WebAssemblyHostBuilder.CreateDefault(args); + var configuration = builder.Configuration; + + builder.RootComponents.Add("#app"); + builder.RootComponents.Add("head::after"); + + builder.Services.AddMudServices(); + builder.Services.AddInfrastructure(configuration); + + var host = builder.Build(); + + await host.RunAsync(); + } +} diff --git a/UIs/Merchants/Source/Properties/launchSettings.json b/UIs/Merchants/Source/Properties/launchSettings.json new file mode 100644 index 0000000..081cf7f --- /dev/null +++ b/UIs/Merchants/Source/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5136", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7112;http://localhost:5136", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/UIs/Merchants/Source/_Imports.razor b/UIs/Merchants/Source/_Imports.razor new file mode 100644 index 0000000..2842e0a --- /dev/null +++ b/UIs/Merchants/Source/_Imports.razor @@ -0,0 +1,28 @@ +@using System.Net.Http +@using System.Net.Http.Json + +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.JSInterop + +@using Comanda.Merchants.WebUI +@using Comanda.Merchants.WebUI.Pages +@using Comanda.Merchants.WebUI.Layout +@using Comanda.Merchants.WebUI.Components +@using Comanda.Merchants.WebUI.Components.Dialogs +@using Comanda.Merchants.WebUI.Forms +@using Comanda.Merchants.WebUI.Forms.Schemes +@using Comanda.Merchants.WebUI.Constants +@using Comanda.Merchants.WebUI.Contracts + +@using Comanda.Internal.Contracts.Transport.Internal.Stores +@using Comanda.Internal.Contracts.Transport.Internal.Profiles +@using Comanda.Internal.Contracts.Transport.Internal.Products +@using Comanda.Internal.Contracts.Transport.Internal + +@using HttpsRichardy.Internal.Essentials.Filtering +@using MudBlazor diff --git a/UIs/Merchants/Source/_Usings.cs b/UIs/Merchants/Source/_Usings.cs new file mode 100644 index 0000000..81e5d86 --- /dev/null +++ b/UIs/Merchants/Source/_Usings.cs @@ -0,0 +1,37 @@ +global using System.Text.Json; +global using System.Net.Http.Json; +global using System.ComponentModel.DataAnnotations; + +global using System.Security.Claims; +global using System.IdentityModel.Tokens.Jwt; + +global using Microsoft.JSInterop; +global using Microsoft.AspNetCore.Components.Web; +global using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +global using Microsoft.AspNetCore.Components.Authorization; + +global using Comanda.Merchants.WebUI.Extensions; +global using Comanda.Merchants.WebUI.Contracts; +global using Comanda.Merchants.WebUI.Constants; +global using Comanda.Merchants.WebUI.Gateways; +global using Comanda.Merchants.WebUI.Authentication; + +global using Comanda.Merchants.WebUI.Http.Clients; +global using Comanda.Merchants.WebUI.Http.Payloads.Identity; +global using Comanda.Merchants.WebUI.Http.Interceptors; + +global using Comanda.Internal.Contracts.Clients; +global using Comanda.Internal.Contracts.Clients.Interfaces; +global using Comanda.Internal.Contracts.Transport.Internal.Stores; +global using Comanda.Internal.Contracts.Transport.Internal.Profiles; + +global using HttpsRichardy.Federation.Sdk.Contracts.Clients; +global using HttpsRichardy.Federation.Sdk.Contracts.Errors; +global using HttpsRichardy.Federation.Sdk.Contracts.Payloads.Identity; +global using HttpsRichardy.Federation.Sdk.Contracts.Payloads.User; +global using HttpsRichardy.Federation.Sdk.Contracts.Payloads.Group; +global using HttpsRichardy.Federation.Sdk.Contracts.Payloads.Permission; + +global using HttpsRichardy.Internal.Essentials.Patterns; +global using HttpsRichardy.Internal.Essentials.Filtering; +global using MudBlazor.Services; diff --git a/UIs/Merchants/Source/wwwroot/appsettings.json b/UIs/Merchants/Source/wwwroot/appsettings.json new file mode 100644 index 0000000..ad239d4 --- /dev/null +++ b/UIs/Merchants/Source/wwwroot/appsettings.json @@ -0,0 +1,8 @@ +{ + "Settings": { + "Gateway": "https://sandbox-comanda-orchestrator.o1iq6t.easypanel.host/api/v1/", + "Blob": "https://sandbox-comanda-stores.o1iq6t.easypanel.host/", + "IdentityProvider": "https://sandbox-federation.o1iq6t.easypanel.host/", + "Realm": "comanda" + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/wwwroot/css/app.css b/UIs/Merchants/Source/wwwroot/css/app.css new file mode 100644 index 0000000..2787f51 --- /dev/null +++ b/UIs/Merchants/Source/wwwroot/css/app.css @@ -0,0 +1,77 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + +.blazor-error-boundary::after { + content: "An error has occurred." +} + +/* Modern Loading Screen - Simple */ +.loading-container { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); + z-index: 9999; +} + +.loading-dots { + display: flex; + gap: 0.75rem; +} + +.loading-dots span { + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: #EE254B; + animation: pulse 1.4s ease-in-out infinite; +} + +.loading-dots span:nth-child(1) { + animation-delay: 0s; +} + +.loading-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.loading-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.5); + opacity: 0.5; + } +} \ No newline at end of file diff --git a/UIs/Merchants/Source/wwwroot/favicon.png b/UIs/Merchants/Source/wwwroot/favicon.png new file mode 100644 index 0000000..8422b59 Binary files /dev/null and b/UIs/Merchants/Source/wwwroot/favicon.png differ diff --git a/UIs/Merchants/Source/wwwroot/icon-192.png b/UIs/Merchants/Source/wwwroot/icon-192.png new file mode 100644 index 0000000..166f56d Binary files /dev/null and b/UIs/Merchants/Source/wwwroot/icon-192.png differ diff --git a/UIs/Merchants/Source/wwwroot/index.html b/UIs/Merchants/Source/wwwroot/index.html new file mode 100644 index 0000000..c1c16b4 --- /dev/null +++ b/UIs/Merchants/Source/wwwroot/index.html @@ -0,0 +1,36 @@ + + + + + + + Comanda.Merchants.WebUI + + + + + + + + + + + + + + + + + + + + + + An unhandled error has occurred. + Reload + 🗙 + + + + +