From 54d6c1078fb0829a7ada2acd00763f2619ebe61e Mon Sep 17 00:00:00 2001 From: Richard Garcia Date: Sat, 28 Feb 2026 00:41:25 -0300 Subject: [PATCH] feature: this commit introduces a Blazor WebAssembly project, and initial settings (appsettings, launchSettings, index.html, favicon, etc.). Implements JWT authentication, session providers, HTTP gateways, and local storage. Includes main layout, sidebar, navigation, WhatsApp support, and UI components for dashboard, products, kitchen, and settings. Adds forms, dialogs, validation schemas, and onboarding, error, and listing pages. --- UIs/Merchants/Comanda.Merchants.WebUI.sln | 42 +++ UIs/Merchants/Source/App.razor | 80 ++++++ .../JwtAuthenticationStateProvider.cs | 34 +++ .../Authentication/PrincipalProvider.cs | 33 +++ .../Source/Authentication/SessionManager.cs | 28 ++ .../Source/Comanda.Merchants.WebUI.csproj | 22 ++ .../Source/Components/AnalyticsView.razor | 168 ++++++++++++ .../Dialogs/ProductCreationDialog.razor | 20 ++ .../Dialogs/ProductUpdateDialog.razor | 28 ++ .../Source/Components/KitchenBoard.razor | 164 +++++++++++ .../Source/Components/PaginatedView.razor | 65 +++++ .../Source/Components/ProductCard.razor | 75 +++++ .../Source/Components/ProductGallery.razor | 68 +++++ .../Source/Components/ProductSearch.razor | 107 ++++++++ UIs/Merchants/Source/Components/Support.razor | 22 ++ .../Constants/AuthenticationDefaults.cs | 7 + UIs/Merchants/Source/Constants/Headers.cs | 6 + .../Source/Constants/SidebarLabels.cs | 10 + UIs/Merchants/Source/Constants/Storage.cs | 10 + .../Source/Contracts/ILocalStorageGateway.cs | 13 + .../Source/Contracts/IPrincipalProvider.cs | 6 + .../Source/Contracts/ISessionManager.cs | 7 + .../Extensions/AuthenticationExtension.cs | 14 + .../Source/Extensions/ClientExtension.cs | 33 +++ .../Source/Extensions/GatewaysExtension.cs | 30 ++ .../Source/Extensions/ServicesExtension.cs | 11 + .../Forms/EstablishmentUpdateForm.razor | 195 +++++++++++++ .../Forms/IntegrationCredentialsForm.razor | 157 +++++++++++ .../Source/Forms/OnboardingForm.razor | 120 ++++++++ .../Source/Forms/ProductCreationForm.razor | 172 ++++++++++++ .../Source/Forms/ProductUpdateForm.razor | 110 ++++++++ .../Forms/Schemes/EstablishmentFormScheme.cs | 12 + .../Schemes/EstablishmentUpdateFormScheme.cs | 19 ++ .../IntegrationCredentialsFormScheme.cs | 9 + .../Schemes/ProductCreationFormScheme.cs | 16 ++ .../Forms/Schemes/ProductUpdateFormScheme.cs | 16 ++ .../Source/Gateways/LocalStorageGateway.cs | 38 +++ .../Source/Http/Clients/IdentityClient.cs | 80 ++++++ .../Interceptors/AuthenticationInterceptor.cs | 16 ++ .../Http/Payloads/Identity/PrincipalScheme.cs | 13 + UIs/Merchants/Source/Layout/MainLayout.razor | 31 +++ .../Source/Layout/NavigationBar.razor | 7 + UIs/Merchants/Source/Layout/Sidebar.razor | 57 ++++ UIs/Merchants/Source/Pages/Home.razor | 7 + UIs/Merchants/Source/Pages/Kitchen.razor | 33 +++ .../Source/Pages/NotAuthorized.razor | 28 ++ UIs/Merchants/Source/Pages/NotFound.razor | 28 ++ UIs/Merchants/Source/Pages/Onboarding.razor | 70 +++++ UIs/Merchants/Source/Pages/Products.razor | 258 ++++++++++++++++++ UIs/Merchants/Source/Pages/Settings.razor | 64 +++++ UIs/Merchants/Source/Program.cs | 20 ++ .../Source/Properties/launchSettings.json | 25 ++ UIs/Merchants/Source/_Imports.razor | 28 ++ UIs/Merchants/Source/_Usings.cs | 37 +++ UIs/Merchants/Source/wwwroot/appsettings.json | 8 + UIs/Merchants/Source/wwwroot/css/app.css | 77 ++++++ UIs/Merchants/Source/wwwroot/favicon.png | Bin 0 -> 1148 bytes UIs/Merchants/Source/wwwroot/icon-192.png | Bin 0 -> 2626 bytes UIs/Merchants/Source/wwwroot/index.html | 36 +++ 59 files changed, 2890 insertions(+) create mode 100644 UIs/Merchants/Comanda.Merchants.WebUI.sln create mode 100644 UIs/Merchants/Source/App.razor create mode 100644 UIs/Merchants/Source/Authentication/JwtAuthenticationStateProvider.cs create mode 100644 UIs/Merchants/Source/Authentication/PrincipalProvider.cs create mode 100644 UIs/Merchants/Source/Authentication/SessionManager.cs create mode 100644 UIs/Merchants/Source/Comanda.Merchants.WebUI.csproj create mode 100644 UIs/Merchants/Source/Components/AnalyticsView.razor create mode 100644 UIs/Merchants/Source/Components/Dialogs/ProductCreationDialog.razor create mode 100644 UIs/Merchants/Source/Components/Dialogs/ProductUpdateDialog.razor create mode 100644 UIs/Merchants/Source/Components/KitchenBoard.razor create mode 100644 UIs/Merchants/Source/Components/PaginatedView.razor create mode 100644 UIs/Merchants/Source/Components/ProductCard.razor create mode 100644 UIs/Merchants/Source/Components/ProductGallery.razor create mode 100644 UIs/Merchants/Source/Components/ProductSearch.razor create mode 100644 UIs/Merchants/Source/Components/Support.razor create mode 100644 UIs/Merchants/Source/Constants/AuthenticationDefaults.cs create mode 100644 UIs/Merchants/Source/Constants/Headers.cs create mode 100644 UIs/Merchants/Source/Constants/SidebarLabels.cs create mode 100644 UIs/Merchants/Source/Constants/Storage.cs create mode 100644 UIs/Merchants/Source/Contracts/ILocalStorageGateway.cs create mode 100644 UIs/Merchants/Source/Contracts/IPrincipalProvider.cs create mode 100644 UIs/Merchants/Source/Contracts/ISessionManager.cs create mode 100644 UIs/Merchants/Source/Extensions/AuthenticationExtension.cs create mode 100644 UIs/Merchants/Source/Extensions/ClientExtension.cs create mode 100644 UIs/Merchants/Source/Extensions/GatewaysExtension.cs create mode 100644 UIs/Merchants/Source/Extensions/ServicesExtension.cs create mode 100644 UIs/Merchants/Source/Forms/EstablishmentUpdateForm.razor create mode 100644 UIs/Merchants/Source/Forms/IntegrationCredentialsForm.razor create mode 100644 UIs/Merchants/Source/Forms/OnboardingForm.razor create mode 100644 UIs/Merchants/Source/Forms/ProductCreationForm.razor create mode 100644 UIs/Merchants/Source/Forms/ProductUpdateForm.razor create mode 100644 UIs/Merchants/Source/Forms/Schemes/EstablishmentFormScheme.cs create mode 100644 UIs/Merchants/Source/Forms/Schemes/EstablishmentUpdateFormScheme.cs create mode 100644 UIs/Merchants/Source/Forms/Schemes/IntegrationCredentialsFormScheme.cs create mode 100644 UIs/Merchants/Source/Forms/Schemes/ProductCreationFormScheme.cs create mode 100644 UIs/Merchants/Source/Forms/Schemes/ProductUpdateFormScheme.cs create mode 100644 UIs/Merchants/Source/Gateways/LocalStorageGateway.cs create mode 100644 UIs/Merchants/Source/Http/Clients/IdentityClient.cs create mode 100644 UIs/Merchants/Source/Http/Interceptors/AuthenticationInterceptor.cs create mode 100644 UIs/Merchants/Source/Http/Payloads/Identity/PrincipalScheme.cs create mode 100644 UIs/Merchants/Source/Layout/MainLayout.razor create mode 100644 UIs/Merchants/Source/Layout/NavigationBar.razor create mode 100644 UIs/Merchants/Source/Layout/Sidebar.razor create mode 100644 UIs/Merchants/Source/Pages/Home.razor create mode 100644 UIs/Merchants/Source/Pages/Kitchen.razor create mode 100644 UIs/Merchants/Source/Pages/NotAuthorized.razor create mode 100644 UIs/Merchants/Source/Pages/NotFound.razor create mode 100644 UIs/Merchants/Source/Pages/Onboarding.razor create mode 100644 UIs/Merchants/Source/Pages/Products.razor create mode 100644 UIs/Merchants/Source/Pages/Settings.razor create mode 100644 UIs/Merchants/Source/Program.cs create mode 100644 UIs/Merchants/Source/Properties/launchSettings.json create mode 100644 UIs/Merchants/Source/_Imports.razor create mode 100644 UIs/Merchants/Source/_Usings.cs create mode 100644 UIs/Merchants/Source/wwwroot/appsettings.json create mode 100644 UIs/Merchants/Source/wwwroot/css/app.css create mode 100644 UIs/Merchants/Source/wwwroot/favicon.png create mode 100644 UIs/Merchants/Source/wwwroot/icon-192.png create mode 100644 UIs/Merchants/Source/wwwroot/index.html 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 + + + +@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 + { + + +
+ +
+ +
+ + 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 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~v0A9xRwxP|bki~~&uFk>U z#P+PQh zyZ;-jwXKqnKbb6)@RaxQz@vm={%t~VbaZrdbaZrdbaeEeXj>~BG?&`J0XrqR#sSlO zg~N5iUk*15JibvlR1f^^1czzNKWvoJtc!Sj*G37QXbZ8LeD{Fzxgdv#Q{x}ytfZ5q z+^k#NaEp>zX_8~aSaZ`O%B9C&YLHb(mNtgGD&Kezd5S@&C=n~Uy1NWHM`t07VQP^MopUXki{2^#ryd94>UJMYW|(#4qV`kb7eD)Q=~NN zaVIRi@|TJ!Rni8J=5DOutQ#bEyMVr8*;HU|)MEKmVC+IOiDi9y)vz=rdtAUHW$yjt zrj3B7v(>exU=IrzC<+?AE=2vI;%fafM}#ShGDZx=0Nus5QHKdyb9pw&4>4XCpa-o?P(Gnco1CGX|U> z$f+_tA3+V~<{MU^A%eP!8R*-sD9y<>Jc7A(;aC5hVbs;kX9&Sa$JMG!W_BLFQa*hM zri__C@0i0U1X#?)Y=)>JpvTnY6^s;fu#I}K9u>OldV}m!Ch`d1Vs@v9 zb}w(!TvOmSzmMBa9gYvD4xocL2r0ds6%Hs>Z& z#7#o9PGHDmfG%JQq`O5~dt|MAQN@2wyJw_@``7Giyy(yyk(m8U*kk5$X1^;3$a3}N^Lp6hE5!#8l z#~NYHmKAs6IAe&A;bvM8OochRmXN>`D`{N$%#dZCRxp4-dJ?*3P}}T`tYa3?zz5BA zTu7uE#GsDpZ$~j9q=Zq!LYjLbZPXFILZK4?S)C-zE1(dC2d<7nO4-nSCbV#9E|E1MM|V<9>i4h?WX*r*ul1 z5#k6;po8z=fdMiVVz*h+iaTlz#WOYmU^SX5#97H~B32s-#4wk<1NTN#g?LrYieCu> zF7pbOLR;q2D#Q`^t%QcY06*X-jM+ei7%ZuanUTH#9Y%FBi*Z#22({_}3^=BboIsbg zR0#jJ>9QR8SnmtSS6x($?$}6$x+q)697#m${Z@G6Ujf=6iO^S}7P`q8DkH!IHd4lB zDzwxt3BHsPAcXFFY^Fj}(073>NL_$A%v2sUW(CRutd%{G`5ow?L`XYSO*Qu?x+Gzv zBtR}Y6`XF4xX7)Z04D+fH;TMapdQFFameUuHL34NN)r@aF4RO%x&NApeWGtr#mG~M z6sEIZS;Uj1HB1*0hh=O@0q1=Ia@L>-tETu-3n(op+97E z#&~2xggrl(LA|giII;RwBlX2^Q`B{_t}gxNL;iB11gEPC>v` zb4SJ;;BFOB!{chn>?cCeGDKuqI0+!skyWTn*k!WiPNBf=8rn;@y%( znhq%8fj2eAe?`A5mP;TE&iLEmQ^xV%-kmC-8mWao&EUK_^=GW-Y3z ksi~={si~={skwfB0gq6itke#r1ONa407*qoM6N<$g11Kq@c;k- literal 0 HcmV?d00001 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 + 🗙 +
+ + + +