From 8e1b3f2a5dc7f85d068053ddd19bdf6d8808d792 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 18 Mar 2026 15:15:50 +0100 Subject: [PATCH] =?UTF-8?q?nem=20=C3=ADr=20ez=20l=C3=B3szart=20se...=20gyo?= =?UTF-8?q?rs=20rendel=C3=A9s,=20deisgn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CustomOrderController.cs | 18 +- .../Controllers/QuickOrderController.cs | 441 ++++++++++ Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs | 156 +++- .../FruitBankMessageTokenProvider.cs | 237 ++++++ .../Infrastructure/PluginNopStartup.cs | 12 +- .../Infrastructure/RouteProvider.cs | 36 +- .../Localization/quickorder.en.xml | 177 ++++ .../Localization/quickorder.hu.xml | 177 ++++ .../Nop.Plugin.Misc.FruitBankPlugin.csproj | 7 + Nop.Plugin.Misc.AIPlugin/SKILL.md | 351 ++++++++ .../Services/CustomPriceCalculationService.cs | 28 +- .../Views/QuickOrder/Index.cshtml | 566 +++++++++++++ Nop.Plugin.Misc.AIPlugin/css/quick-order.css | 756 ++++++++++++++++++ 13 files changed, 2918 insertions(+), 44 deletions(-) create mode 100644 Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml create mode 100644 Nop.Plugin.Misc.AIPlugin/SKILL.md create mode 100644 Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml create mode 100644 Nop.Plugin.Misc.AIPlugin/css/quick-order.css diff --git a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs index d873cf0..09744fc 100644 --- a/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs +++ b/Nop.Plugin.Misc.AIPlugin/Areas/Admin/Controllers/CustomOrderController.cs @@ -591,6 +591,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers throw new Exception($"{errorText}"); } + //itt vajon elég ez a vizsgálat, vagy a priceCalculationService.GetFinalPriceAsync-al kéne lekérni a végső árat és azt összehasonlítani? - A. + //ha kedvezménye is van, de manuálisan is le van csökkentve az ár, akkor a kedvezményt látja a rendszer, és azt kellene összevetni a bejövő árral... - A. if (orderProductItem.Price != product.Price) { //manual price change @@ -600,14 +602,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers { unitPricesIncludeDiscounts = true; } - - + + //itt ha includeDiscounts van, akkor már a beírt ár megy be? var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store); + _logger.Detail($"Adding order item: ProductId: {orderItem.ProductId}, Quantity: {orderItem.Quantity}, UnitPriceInclTax: {orderItem.UnitPriceInclTax}, UnitPriceExclTax: {orderItem.UnitPriceExclTax}, PriceInclTax: {orderItem.PriceInclTax}, PriceExclTax: {orderItem.PriceExclTax}"); + await _orderService.InsertOrderItemAsync(orderItem); await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id)); - var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: false); + var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true); var unitPriceInclTaxValue = priceCalculation.finalPrice; var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer); @@ -615,8 +619,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers order.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity; order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity; - order.OrderSubTotalDiscountInclTax += order.OrderSubtotalInclTax - orderItem.PriceInclTax; - order.OrderSubTotalDiscountExclTax += order.OrderSubtotalExclTax - orderItem.PriceExclTax; + var appliedDiscounts = priceCalculation.appliedDiscountAmount; + var totalDiscountInclTax = appliedDiscounts * orderProductItem.Quantity; + var totalDiscountExclTax = appliedDiscounts * orderProductItem.Quantity; + + order.OrderSubTotalDiscountInclTax += totalDiscountInclTax; + order.OrderSubTotalDiscountExclTax += totalDiscountExclTax; //order.OrderTax //order.TaxRates diff --git a/Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs b/Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs new file mode 100644 index 0000000..c4c92b9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Controllers/QuickOrderController.cs @@ -0,0 +1,441 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Nop.Core; +using Nop.Core.Domain.Orders; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Plugin.Misc.FruitBankPlugin.Services; +using Nop.Services.Catalog; +using Nop.Services.Customers; +using Nop.Services.Localization; +using Nop.Services.Orders; +using Nop.Web.Framework.Controllers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers +{ + [AutoValidateAntiforgeryToken] + public class QuickOrderController : BasePluginController + { + private readonly IWorkContext _workContext; + private readonly IStoreContext _storeContext; + private readonly IProductService _productService; + private readonly IShoppingCartService _shoppingCartService; + private readonly ICustomerService _customerService; + private readonly ILocalizationService _localizationService; + private readonly CustomPriceCalculationService _customPriceCalculationService; + private readonly OpenAIApiService _aiApiService; + private readonly CerebrasAPIService _cerebrasApiService; + private readonly FruitBankDbContext _dbContext; + + // Resource key prefix + private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder."; + + public QuickOrderController( + IWorkContext workContext, + IStoreContext storeContext, + IProductService productService, + IShoppingCartService shoppingCartService, + ICustomerService customerService, + ILocalizationService localizationService, + IPriceCalculationService priceCalculationService, + OpenAIApiService aiApiService, + CerebrasAPIService cerebrasApiService, + FruitBankDbContext dbContext) + { + _workContext = workContext; + _storeContext = storeContext; + _productService = productService; + _shoppingCartService = shoppingCartService; + _customerService = customerService; + _localizationService = localizationService; + _customPriceCalculationService = priceCalculationService as CustomPriceCalculationService; + _aiApiService = aiApiService; + _cerebrasApiService = cerebrasApiService; + _dbContext = dbContext; + } + + [HttpGet] + public async Task Index() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Challenge(); + + return View("~/Plugins/Misc.FruitBankPlugin/Views/QuickOrder/Index.cshtml"); + } + + /// + /// Return all available products with prices (for initial page load) + /// + [HttpGet] + public async Task GetAllProducts() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + try + { + var store = await _storeContext.GetCurrentStoreAsync(); + var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync()).Where(pd => pd.AvailableQuantity > 0); + + //var dbProducts = await _productService.SearchProductsAsync( + // pageIndex: 0, + // pageSize: 500, + // orderBy: ); + + var result = new List(); + + foreach (var product in allProductDtos) + { + var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id); + if (productDto == null) continue; + + var availableQty = product.StockQuantity + productDto.IncomingQuantity; + if (availableQty <= 0) continue; + + decimal? unitPrice = null; + if (!productDto.IsMeasurable) + { + var tproduct = await _productService.GetProductByIdAsync(productDto.Id); + var priceResult = await _customPriceCalculationService.GetFinalPriceAsync( + tproduct, customer, store, null, 0, true, 1, null, null); + unitPrice = priceResult.finalPrice; + } + + result.Add(new + { + id = product.Id, + name = product.Name, + quantity = 1, + requestedQuantity = 1, + unitPrice, + stockQuantity = availableQty, + searchTerm = (string)null, + isQuantityReduced = false, + isMeasurable = productDto.IsMeasurable + }); + } + + return Json(new { success = true, products = result }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] GetAllProducts error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Parse a manually typed product list and return matching products with prices + /// + [HttpPost] + public async Task SearchProducts(string text) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + if (string.IsNullOrWhiteSpace(text)) + return Json(new { success = false, message = await L("NoTextProvided") }); + + try + { + var parsedProducts = await ParseProductsFromText(text); + if (parsedProducts == null || parsedProducts.Count == 0) + return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = text }); + + var store = await _storeContext.GetCurrentStoreAsync(); + var enrichedProducts = await EnrichProductData(parsedProducts, customer, store); + + return Json(new { success = true, transcription = text, products = enrichedProducts }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] SearchProducts error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Transcribe voice audio (Hungarian) then parse and match products + /// + [HttpPost] + public async Task TranscribeAndSearch(IFormFile audioFile) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + if (audioFile == null || audioFile.Length == 0) + return Json(new { success = false, message = await L("NoAudioReceived") }); + + try + { + var transcribedText = await TranscribeAudioFile(audioFile, "hu"); + if (string.IsNullOrEmpty(transcribedText)) + return Json(new { success = false, message = await L("TranscriptionFailed") }); + + Console.WriteLine($"[QuickOrder] Transcription: {transcribedText}"); + + var parsedProducts = await ParseProductsFromText(transcribedText); + if (parsedProducts == null || parsedProducts.Count == 0) + return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = transcribedText }); + + var store = await _storeContext.GetCurrentStoreAsync(); + var enrichedProducts = await EnrichProductData(parsedProducts, customer, store); + + return Json(new { success = true, transcription = transcribedText, products = enrichedProducts }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] TranscribeAndSearch error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Add a product to the current customer's shopping cart and return the updated cart + /// + [HttpPost] + public async Task AddToCart(int productId, int quantity) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false, message = await L("NotLoggedIn") }); + + if (productId <= 0 || quantity <= 0) + return Json(new { success = false, message = await L("InvalidProductOrQuantity") }); + + try + { + var product = await _productService.GetProductByIdAsync(productId); + if (product == null || product.Deleted || !product.Published) + return Json(new { success = false, message = await L("ProductNotAvailable") }); + + var store = await _storeContext.GetCurrentStoreAsync(); + + var warnings = await _shoppingCartService.AddToCartAsync( + customer: customer, + product: product, + shoppingCartType: ShoppingCartType.ShoppingCart, + storeId: store.Id, + quantity: quantity); + + if (warnings.Any()) + return Json(new { success = false, message = string.Join("; ", warnings) }); + + var cartItems = await GetCartItemsJson(customer, store); + return Json(new { success = true, cartItems }); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] AddToCart error: {ex.Message}"); + return Json(new { success = false, message = $"Hiba: {ex.Message}" }); + } + } + + /// + /// Return the current customer's cart as JSON (for cart panel refresh) + /// + [HttpGet] + public async Task GetCartItems() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + if (await _customerService.IsGuestAsync(customer)) + return Json(new { success = false }); + + var store = await _storeContext.GetCurrentStoreAsync(); + var cartItems = await GetCartItemsJson(customer, store); + return Json(new { success = true, cartItems }); + } + + #region Private helpers + + /// Shorthand: get a localized QuickOrder resource string + private Task L(string keySuffix) + => _localizationService.GetResourceAsync(Prefix + keySuffix); + + private async Task TranscribeAudioFile(IFormFile audioFile, string language) + { + var fileName = $"quick_order_{DateTime.Now:yyyyMMdd_HHmmss}.webm"; + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice"); + + if (!Directory.Exists(uploadsFolder)) + Directory.CreateDirectory(uploadsFolder); + + var filePath = Path.Combine(uploadsFolder, fileName); + using (var stream = new FileStream(filePath, FileMode.Create)) + await audioFile.CopyToAsync(stream); + + string transcribedText; + using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null); + + if (!string.IsNullOrEmpty(transcribedText) && + (transcribedText.EndsWith(".") || transcribedText.EndsWith("!") || transcribedText.EndsWith("?"))) + transcribedText = transcribedText[..^1]; + + try { System.IO.File.Delete(filePath); } catch { /* ignore cleanup errors */ } + return transcribedText; + } + + private async Task> ParseProductsFromText(string text) + { + var systemPrompt = @"You are a product parser for a Hungarian fruit and vegetable wholesale company. +Parse the product names and quantities from the user's input. + +CRITICAL RULES: +1. Extract product names and quantities from ANY produce item +2. Normalize product names to singular, lowercase (e.g., 'narancsok' → 'narancs') +3. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, 'húsz' = 20, 'tíz' = 10, 'öt' = 5, 'egy' = 1) +4. Fix common transcription/typing errors (e.g., 'datója' → 'datolya', 'szűlő' → 'szőlő', 'mondarin' → 'mandarin') +5. Return ONLY valid JSON array, no explanations +6. DO NOT include units - only product name and quantity as a number +7. ALWAYS return at least one product if you can parse anything from the input + +OUTPUT FORMAT (JSON only): +[ + {""product"": ""narancs"", ""quantity"": 100}, + {""product"": ""alma"", ""quantity"": 50} +]"; + + var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}"); + Console.WriteLine($"[QuickOrder] AI parse response: {aiResponse}"); + + var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline); + if (!jsonMatch.Success) return new List(); + + try + { + return System.Text.Json.JsonSerializer.Deserialize>(jsonMatch.Value) + ?? new List(); + } + catch (Exception ex) + { + Console.WriteLine($"[QuickOrder] JSON parse error: {ex.Message}"); + return new List(); + } + } + + private async Task> EnrichProductData( + List parsedProducts, + Nop.Core.Domain.Customers.Customer customer, + Nop.Core.Domain.Stores.Store store) + { + var enrichedProducts = new List(); + var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync(); + + foreach (var parsed in parsedProducts) + { + var dbProducts = await _productService.SearchProductsAsync( + keywords: parsed.Product, + pageIndex: 0, + pageSize: 20); + + if (!dbProducts.Any()) + { + Console.WriteLine($"[QuickOrder] No products found for: {parsed.Product}"); + continue; + } + + foreach (var product in dbProducts) + { + var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id); + if (productDto == null) continue; + + var availableQty = product.StockQuantity + productDto.IncomingQuantity; + if (availableQty <= 0) continue; + + var requestedQty = parsed.Quantity; + var finalQty = Math.Min(requestedQty, availableQty); + var isReduced = finalQty < requestedQty; + + decimal? unitPrice = null; + if (!productDto.IsMeasurable) + { + var priceResult = await _customPriceCalculationService.GetFinalPriceAsync( + product, customer, store, null, 0, true, finalQty, null, null); + unitPrice = priceResult.finalPrice; + } + + enrichedProducts.Add(new + { + id = product.Id, + name = product.Name, + quantity = finalQty, + requestedQuantity = requestedQty, + unitPrice, + stockQuantity = availableQty, + searchTerm = parsed.Product, + isQuantityReduced = isReduced, + isMeasurable = productDto.IsMeasurable + }); + } + } + + Console.WriteLine($"[QuickOrder] Enriched product count: {enrichedProducts.Count}"); + return enrichedProducts; + } + + private async Task> GetCartItemsJson( + Nop.Core.Domain.Customers.Customer customer, + Nop.Core.Domain.Stores.Store store) + { + var cart = await _shoppingCartService.GetShoppingCartAsync( + customer, ShoppingCartType.ShoppingCart, store.Id); + + var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync(); + var result = new List(); + + foreach (var item in cart) + { + var product = await _productService.GetProductByIdAsync(item.ProductId); + if (product == null) continue; + + var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id); + var isMeasurable = productDto?.IsMeasurable ?? false; + + decimal? unitPrice = null; + if (!isMeasurable) + { + var priceResult = await _customPriceCalculationService.GetFinalPriceAsync( + product, customer, store, null, 0, true, item.Quantity, null, null); + unitPrice = priceResult.finalPrice; + } + + result.Add(new + { + id = item.Id, + productId = item.ProductId, + name = product.Name, + quantity = item.Quantity, + unitPrice, + isMeasurable + }); + } + + return result; + } + + #endregion + + #region Inner models + + private class ParsedProduct + { + [System.Text.Json.Serialization.JsonPropertyName("product")] + public string Product { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("quantity")] + public int Quantity { get; set; } + } + + #endregion + } +} diff --git a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs index 4d5f0dd..49f2c2f 100644 --- a/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs +++ b/Nop.Plugin.Misc.AIPlugin/FruitBankPlugin.cs @@ -1,4 +1,3 @@ - using FruitBank.Common.Server; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -59,7 +58,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin //TODO: Add "IsMeasurable" product attribute - FruitBankConst.IsMeasurableAttributeName //TODO: Add "NeedsToBeMeasured" product attribute if not exists - //TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? TGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kdban fellrja ha azonos key-el vannak! - J. + //TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? ATGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kodban felulirja ha azonos key-el vannak! - J. // Default settings var settings = new FruitBankSettings @@ -67,8 +66,132 @@ namespace Nop.Plugin.Misc.FruitBankPlugin ApiKey = string.Empty }; await _settingService.SaveSettingAsync(settings); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Shipment", "EN"); - await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Szlltmnyok", "HU"); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Sz\u00e1ll\u00edtm\u00e1nyok", "HU"); + + // ── Quick Order page ─────────────────────────────────────────────────── + const string en = "EN"; + const string hu = "HU"; + + // Page title + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Quick Order", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Gyors rendel\u00e9s", hu); + + // Navigation menu label + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Quick Order", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Gyors rendel\u00e9s", hu); + + // Search bar + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Start voice recording", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Hangfelv\u00e9tel ind\u00edt\u00e1sa", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Stop", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Le\u00e1ll\u00edt\u00e1s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Search for products (e.g. orange 100, apple 50) or use the microphone...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Keress term\u00e9keket (pl. narancs 100, alma 50) vagy haszn\u00e1ld a mikrofont...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Listening... (start speaking)", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Figye\u0151s... (kezdj el besz\u00e9lni)", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Search", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Keres\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Listening...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Figye\u0151s...", hu); + + // Product panel + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "I heard:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "Hallottam:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "No products found. Try a different search.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "Nem tal\u00e1ltunk term\u00e9keket. Pr\u00f3b\u00e1ljunk m\u00e1s keres\u00e9st.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Loading products...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Term\u00e9kek bet\u00f6lt\u00e9se...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "All products", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "\u00d6sszes term\u00e9k", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Results", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Tal\u00e1latok", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 set quantity, then add to cart:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 \u00e1ll\u00edtsd be a mennyis\u00e9get, majd add a kos\u00e1rhoz:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "Requires weighing", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "S\u00falym\u00e9r\u00e9st ig\u00e9nyel", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "Stock:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "K\u00e9szlet:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Only", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Csak", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "pcs available", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "db el\u00e9rhet\u0151", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "pcs", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "db", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/pcs", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/db", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Add to cart", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Kos\u00e1rba", hu); + + // Cart panel + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Cart", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Kos\u00e1r", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "Your cart is empty.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "A kos\u00e1r \u00fcres.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Search for products and add them.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Keress term\u00e9keket \u00e9s add hozz\u00e1 \u0151ket.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "Prices for weighed items will be finalized after measurement.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "A s\u00falym\u00e9r\u00e9st ig\u00e9nyl\u0151 t\u00e9teleikn\u00e9l az \u00e1r a m\u00e9r\u00e9s ut\u00e1n v\u00e9glegesedik.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Estimated total:", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Becs\u00fclt \u00f6sszeg:", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Proceed to checkout", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Tov\u00e1bb a p\u00e9nzt\u00e1rhoz", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "View cart", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "Kos\u00e1r megtekint\u00e9se", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "added", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "hozz\u00e1adva", hu); + + // JS voice / status strings + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "Your browser does not support audio recording.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "A b\u00f6ng\u00e9sz\u0151 nem t\u00e1mogatja a hangfelv\u00e9telt.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Could not access microphone: ", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Nem siker\u00fclt a mikrofon el\u00e9r\u00e9se: ", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Please allow microphone access.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Enged\u00e9lyezd a mikrofon haszn\u00e1lat\u00e1t.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "No microphone found.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "Nincs mikrofon csatlakoztatva.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Calibrating...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Kalib\u00e1l\u00f3d\u00e1s...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Processing...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Feldolgoz\u00e1s...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Could not record audio. Please try again.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Nem siker\u00fclt hangot r\u00f6gz\u00edteni. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Loud and clear", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Hangos \u00e9s \u00e9rhet\u0151", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Speaking...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Besz\u00e9l...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Speak louder!", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Hangosabban!", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Searching...", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Keres\u00e9s...", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "Please enter the products!", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "K\u00e9rem, add meg a term\u00e9keket!", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Error during search.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Hiba a keres\u00e9s sor\u00e1n.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Error processing audio.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Hiba a hangfeldolgoz\u00e1s sor\u00e1n.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Error adding item to cart.", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Hiba a kos\u00e1rba helyez\u00e9s sor\u00e1n.", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Error: ", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Hiba: ", hu); + + // Controller JSON error messages + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Nincs bejelentkezve", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "No text provided", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "Nincs sz\u00f6veg megadva", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Could not identify products", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Nem siker\u00fclt term\u00e9keket azonos\u00edtani", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "No audio received", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "Nem \u00e9rkezett hangf\u00e1jl", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Speech recognition failed", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Nem siker\u00fclt a hangfelismer\u00e9s", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "Product not available", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "A term\u00e9k nem el\u00e9rhet\u0151", hu); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "Invalid product or quantity", en); + await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu); + await base.InstallAsync(); } @@ -87,33 +210,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin return Task.FromResult>(new List { PublicWidgetZones.ProductBoxAddinfoBefore, PublicWidgetZones.ProductDetailsBottom, AdminWidgetZones.ProductDetailsBlock, AdminWidgetZones.OrderDetailsBlock }); } - //public string GetWidgetViewComponentName(string widgetZone) - //{ - // return "ProductAIWidget"; // A ViewComponent neve - //} - - // --- ADMIN MEN --- - //public async Task ManageSiteMapAsync(AdminMenuItem rootNode) - //{ - // if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS)) - // return; - - // var pluginNode = new AdminMenuItem - // { - // SystemName = "FruitBankPlugin.Configure", - // Title = "AI Assistant", - // Url = $"{_webHelper.GetStoreLocation()}Admin/FruitBankPluginAdmin/Configure", - // Visible = true - // }; - // rootNode.ChildNodes.Add(pluginNode); - // //return Task.CompletedTask; - //} - public async Task ManageSiteMapAsync(AdminMenuItem rootNode) { if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS)) return; - } public override string GetConfigurationPageUrl() @@ -138,15 +238,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin { return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null; } - else if (widgetZone == AdminWidgetZones.OrderDetailsBlock) { return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null; } } - + return null; - } } } diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs new file mode 100644 index 0000000..e5edec1 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/FruitBankMessageTokenProvider.cs @@ -0,0 +1,237 @@ +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Nop.Core; +using Nop.Core.Domain; +using Nop.Core.Domain.Catalog; +using Nop.Core.Domain.Common; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Directory; +using Nop.Core.Domain.Messages; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Core.Domain.Tax; +using Nop.Core.Domain.Vendors; +using Nop.Core.Events; +using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; +using Nop.Services.Attributes; +using Nop.Services.Blogs; +using Nop.Services.Catalog; +using Nop.Services.Common; +using Nop.Services.Customers; +using Nop.Services.Directory; +using Nop.Services.Events; +using Nop.Services.Helpers; +using Nop.Services.Html; +using Nop.Services.Localization; +using Nop.Services.Logging; +using Nop.Services.Messages; +using Nop.Services.News; +using Nop.Services.Orders; +using Nop.Services.Payments; +using Nop.Services.Seo; +using Nop.Services.Shipping; +using Nop.Services.Stores; +using Nop.Services.Vendors; +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace Nop.Plugin.Misc.FruitBankPlugin.Infrastructure +{ + public class FruitBankMessageTokenProvider : MessageTokenProvider + { + private readonly IOrderService _orderService; + private readonly IPriceFormatter _priceFormatter; + private readonly ICurrencyService _currencyService; + private readonly CurrencySettings _currencySettings; + private readonly FruitBankDbContext _dbContext; + + public FruitBankMessageTokenProvider( + CatalogSettings catalogSettings, + CurrencySettings currencySettings, + IActionContextAccessor actionContextAccessor, + IAddressService addressService, + IAttributeFormatter addressAttributeFormatter, + IAttributeFormatter customerAttributeFormatter, + IAttributeFormatter vendorAttributeFormatter, + IBlogService blogService, + ICountryService countryService, + ICurrencyService currencyService, + ICustomerService customerService, + IDateTimeHelper dateTimeHelper, + IEventPublisher eventPublisher, + IGenericAttributeService genericAttributeService, + IGiftCardService giftCardService, + IHtmlFormatter htmlFormatter, + ILanguageService languageService, + ILocalizationService localizationService, + ILogger logger, + INewsService newsService, + IOrderService orderService, + IPaymentPluginManager paymentPluginManager, + IPaymentService paymentService, + IPriceFormatter priceFormatter, + IProductService productService, + IRewardPointService rewardPointService, + IShipmentService shipmentService, + IStateProvinceService stateProvinceService, + IStoreContext storeContext, + IStoreService storeService, + IUrlHelperFactory urlHelperFactory, + IUrlRecordService urlRecordService, + IWorkContext workContext, + MessageTemplatesSettings templatesSettings, + PaymentSettings paymentSettings, + StoreInformationSettings storeInformationSettings, + TaxSettings taxSettings, + FruitBankDbContext dbContext + ) : base( + catalogSettings, + currencySettings, + actionContextAccessor, + addressService, + addressAttributeFormatter, + customerAttributeFormatter, + vendorAttributeFormatter, + blogService, + countryService, + currencyService, + customerService, + dateTimeHelper, + eventPublisher, + genericAttributeService, + giftCardService, + htmlFormatter, + languageService, + localizationService, + logger, + newsService, + orderService, + paymentPluginManager, + paymentService, + priceFormatter, + productService, + rewardPointService, + shipmentService, + stateProvinceService, + storeContext, + storeService, + urlHelperFactory, + urlRecordService, + workContext, + templatesSettings, + paymentSettings, + storeInformationSettings, + taxSettings) + { + _orderService = orderService; + _priceFormatter = priceFormatter; + _currencyService = currencyService; + _currencySettings = currencySettings; + _dbContext = dbContext; + } + + public override async Task AddOrderTokensAsync( + IList tokens, + Order order, + int languageId, + int vendorId = 0) + { + // Run base first to populate all other Order.* tokens + await base.AddOrderTokensAsync(tokens, order, languageId, vendorId); + + // Replace the product table token with our custom version + var existing = tokens.FirstOrDefault(t => t.Key == "Order.Product(s)"); + if (existing != null) + tokens.Remove(existing); + + tokens.Add(new Token("Order.Product(s)", await BuildCustomProductTableAsync(order, languageId), true)); + } + + private async Task BuildCustomProductTableAsync(Order order, int languageId) + { + var currency = await _currencyService.GetCurrencyByCodeAsync(order.CustomerCurrencyCode) + ?? await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId); + + var items = await _orderService.GetOrderItemsAsync(order.Id); + var itemDtos = await _dbContext.OrderItemDtos.GetAllByOrderId(order.Id).ToListAsync(); + + var sb = new StringBuilder(); + + sb.AppendLine(@" + + + + + + + + + + "); + + var rowIndex = 0; + foreach (var item in itemDtos) + { + var product = await _orderService.GetProductByOrderItemIdAsync(item.Id); + if (product == null) continue; + + var unitPrice = await _priceFormatter.FormatPriceAsync( + item.UnitPriceInclTax, true, currency, languageId, true); + var lineTotal = await _priceFormatter.FormatPriceAsync( + item.PriceInclTax, true, currency, languageId, true); + + var rowBg = rowIndex % 2 == 0 ? "#ffffff" : "#f2f7f0"; + rowIndex++; + if (item.IsMeasurable) + { + var averageWeight = item.AverageWeight; + var approximatePrice = item.Quantity * item.UnitPriceInclTax * (decimal)averageWeight; + sb.AppendLine($@" + + + + + + "); + } + else + { + sb.AppendLine($@" + + + + + + "); + } + } + + var orderTotal = await _priceFormatter.FormatPriceAsync( + order.OrderTotal, true, currency, languageId, true); + + if(itemDtos.Any(i => i.IsMeasurable)) + { + sb.AppendLine($@" + + + + "); + } + + else + { + sb.AppendLine($@" + + + + "); + + } + + + sb.AppendLine(" \n
TermékMennyiségEgységárÖsszesen
{product.Name}{item.Quantity}{unitPrice}Kalkuláció alatt, nagyságrendileg {approximatePrice}
{product.Name}{item.Quantity}{unitPrice}{lineTotal}
Végösszeg:Mérendő termék miatt kalkuláció alatt...
Végösszeg:{orderTotal}
"); + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs index 18bcd93..2d81000 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/PluginNopStartup.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Nop.Core.Domain.Orders; using Nop.Core.Infrastructure; using Nop.Data; @@ -90,8 +91,15 @@ public class PluginNopStartup : INopStartup services.AddScoped(); //services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + //services.AddScoped(); + //services.AddScoped(); + services.Replace( + ServiceDescriptor.Scoped() + ); + //services.AddScoped(); + services.Replace( + ServiceDescriptor.Scoped() + ); services.AddScoped, EventConsumer>(); services.AddScoped(); services.AddScoped(); diff --git a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs index 7fe2e05..6e2d201 100644 --- a/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs +++ b/Nop.Plugin.Misc.AIPlugin/Infrastructure/RouteProvider.cs @@ -27,7 +27,6 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.Order.List", pattern: "Admin/Order/List", defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN } - //constraints: new { area = AreaNames.ADMIN } ); endpointRouteBuilder.MapControllerRoute( @@ -39,14 +38,12 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.Order.Test", pattern: "Admin/Order/Test", defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN } - //constraints: new { area = AreaNames.ADMIN } ); endpointRouteBuilder.MapControllerRoute( name: "Plugin.FruitBank.Admin.Index", pattern: "Admin", defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN } - //constraints: new { area = AreaNames.ADMIN } ); endpointRouteBuilder.MapControllerRoute( @@ -181,10 +178,41 @@ public class RouteProvider : IRouteProvider name: "Plugin.FruitBank.Admin.ExtractText", pattern: "Admin/ExtractText", defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN }); + + // ── Public: Quick Order ────────────────────────────────────────────── + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.Index", + pattern: "gyors-rendeles", + defaults: new { controller = "QuickOrder", action = "Index" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.GetAllProducts", + pattern: "gyors-rendeles/osszes-termek", + defaults: new { controller = "QuickOrder", action = "GetAllProducts" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.SearchProducts", + pattern: "gyors-rendeles/kereses", + defaults: new { controller = "QuickOrder", action = "SearchProducts" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.TranscribeAndSearch", + pattern: "gyors-rendeles/hang", + defaults: new { controller = "QuickOrder", action = "TranscribeAndSearch" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.AddToCart", + pattern: "gyors-rendeles/kosarba", + defaults: new { controller = "QuickOrder", action = "AddToCart" }); + + endpointRouteBuilder.MapControllerRoute( + name: "Plugin.FruitBank.QuickOrder.GetCartItems", + pattern: "gyors-rendeles/kosar", + defaults: new { controller = "QuickOrder", action = "GetCartItems" }); } /// /// Gets a priority of route provider /// public int Priority => 4000; -} \ No newline at end of file +} diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml new file mode 100644 index 0000000..2b6c5a3 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.en.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml new file mode 100644 index 0000000..53e0ea7 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Localization/quickorder.hu.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj index 9803096..a5ecaa7 100644 --- a/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj +++ b/Nop.Plugin.Misc.AIPlugin/Nop.Plugin.Misc.FruitBankPlugin.csproj @@ -10,6 +10,7 @@ + @@ -65,6 +66,9 @@ PreserveNewest Always + + Always + PreserveNewest @@ -661,6 +665,9 @@ Always + + Always + diff --git a/Nop.Plugin.Misc.AIPlugin/SKILL.md b/Nop.Plugin.Misc.AIPlugin/SKILL.md new file mode 100644 index 0000000..83b2ff7 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/SKILL.md @@ -0,0 +1,351 @@ +# FruitBank Plugin – Claude Skill Reference + +> **Purpose:** This file is a reference document for Claude to quickly understand the FruitBank NopCommerce plugin codebase, patterns, and conventions so that new work sessions can ramp up without re-reading the entire codebase from scratch. + +--- + +## 1. Project Identity + +| Property | Value | +|---|---| +| Plugin system name | `Misc.FruitBankPlugin` | +| DLL | `Nop.Plugin.Misc.FruitBankPlugin.dll` | +| Namespace root | `Nop.Plugin.Misc.FruitBankPlugin` | +| NopCommerce version | **4.80** | +| Author | Adam Gelencser | +| Plugin source path | `D:\REPOS\MANGO\source\Nopcommerce.Common\4.70\Plugins\Nop.Plugin.Misc.AIPlugin` | +| Theme path | `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven` | + +The plugin is called **AIPlugin** on disk (folder/csproj) but the assembly and namespace use `FruitBankPlugin`. Both names are the same thing. + +--- + +## 2. Business Domain + +FruitBank is a **Hungarian fruit and vegetable wholesale company** running a private B2B NopCommerce webshop. The typical user is a warehouse employee or admin working on mobile. Key business concepts: + +- **Partners** – business customers (companies), matched by name across multiple systems +- **Shipping documents** – PDF/image documents received from suppliers, parsed by AI +- **Measurable products** – products that require physical weighing before price is finalized; `IsMeasurable` is determined server-side only +- **Stock taking** – periodic inventory audit workflow with discrepancy reports +- **InnVoice** – external accounting/invoicing system, synced via `InnVoiceOrderService` / `InnVoiceApiService` +- **Voice ordering** – warehouse staff dictate orders in Hungarian; transcribed via Whisper + +--- + +## 3. Folder Structure + +``` +Nop.Plugin.Misc.AIPlugin/ +├── Areas/Admin/ +│ ├── Controllers/ # All admin-area controllers +│ ├── Components/ # Admin view components +│ ├── Factories/ # CustomOrderModelFactory, CustomProductModelFactory +│ ├── Models/ # Admin view models (extended Nop models) +│ ├── Validators/ +│ └── Views/ # Admin Razor views; custom layouts: _FruitBankAdminLayout.cshtml +├── Controllers/ # Public-facing controllers (QuickOrder, Checkout, FruitBankData) +├── Components/ # Widget view components (ProductAI, ProductAttributes, OrderAttributes) +├── css/ / js/ # Static assets for the plugin +├── Domains/ +│ └── DataLayer/ # LinqToDB table classes + DbContexts +│ ├── FruitBankDbContext.cs +│ ├── StockTakingDbContext.cs +│ └── *DbTable.cs # One file per custom table +├── Infrastructure/ +│ ├── PluginNopStartup.cs # DI registration + SignalR + middleware +│ ├── RouteProvider.cs +│ ├── ViewLocationExpander.cs +│ └── FruitBankMessageTokenProvider.cs # Overrides IMessageTokenProvider +├── Services/ # Business logic services +├── Localization/ +│ ├── quickorder.en.xml +│ └── quickorder.hu.xml +├── FruitBankPlugin.cs # Main plugin class (IWidgetPlugin) +├── FruitBankSettings.cs # Plugin settings (ApiKey etc.) +├── FruitBankConst.cs # Constants +└── plugin.json +``` + +--- + +## 4. Key Services + +### AI / LLM +| Service | Purpose | +|---|---| +| `OpenAIApiService` | Primary OpenAI integration – chat completions, Whisper transcription | +| `OpenAiService` | Lightweight wrapper using `gpt-4o-mini` for simple prompts | +| `CerebrasAPIService` | Alternative LLM provider | +| `ReplicateService` | Replicate.com API (image/audio models); registered with a hardcoded Bearer token | +| `AICalculationService` | AI-assisted price/measurement calculations | + +### Storage +| Service | Purpose | +|---|---| +| `FileStorageService` | Generic file storage: SHA256 hash dedup, GZip compression, path building | +| `IFileStorageProvider` / `LocalFileStorageProvider` | Strategy pattern storage backend (currently local disk / wwwroot/uploads) | + +**FileStorageService patterns:** +- Calculates SHA256 on upload BEFORE any AI processing → prevents duplicate API calls +- Skips GZip for already-compressed formats (jpg, pdf, mp4, zip, etc.) +- Path format: `{userId}/{featureName}/{entityType}-{entityId}/{fileName}_{id}.ext` +- DB record created first to get ID, then file is saved; rolled back on failure + +### Order / Measurement +| Service | Purpose | +|---|---| +| `OrderMeasurementService` / `IOrderMeasurementService` | Handles orders that contain measurable products | +| `MeasurementService` / `IMeasurementService` | Core weighing logic | +| `InnVoiceOrderService` | Syncs orders with InnVoice accounting system | +| `InnVoiceApiService` | HTTP client for the InnVoice REST API | + +### Infrastructure +| Service | Purpose | +|---|---| +| `FruitBankAttributeService` | Custom product/order attribute helpers | +| `LockService` / `ILockService` | Singleton distributed lock | +| `PdfToImageService` | Converts PDF pages to images for AI vision processing | +| `EventConsumer` | Handles `OrderPlacedEvent` | +| `FruitBankHub` | SignalR hub for real-time admin notifications | + +--- + +## 5. Admin Controllers + +| Controller | Purpose | +|---|---| +| `CustomOrderController` | Extended order management: **split order** feature (audit-based + manual selection modes), order notes, SignalR events | +| `CustomDashboardController` | AI-powered admin dashboard with `GetWelcomeMessageAsync` (store summary, order totals, stock discrepancies, OpenWeatherMap weather) | +| `ShippingController` | Shipping document management + AI PDF extraction workflow | +| `VoiceOrderController` | Voice-to-order admin tool (mobile-optimized) | +| `FruitBankAudioController` | Audio upload/processing endpoint for Whisper transcription | +| `InvoiceController` | Invoice generation and management | +| `InnVoiceOrderController` | InnVoice order sync UI | +| `InnVoiceOrderSyncController` | InnVoice sync API endpoints | +| `ManagementPageController` | General management page | +| `FileManagerController` + `FileManagerScriptsApiController` | File manager UI | +| `FileStorageController` | File storage API endpoints | +| `AppDownloadController` | App download/distribution page | +| `FruitBankPluginAdminController` | Plugin configuration page | +| `CustomProductController` | Extended product admin (IsMeasurable etc.) | + +--- + +## 6. Public Controllers + +| Controller | Route | Purpose | +|---|---|---| +| `QuickOrderController` | `/gyors-rendeles` | Customer-facing quick order page with voice + text search | +| `CheckoutController` | `/checkout/*` | Custom checkout flow override | +| `FruitBankDataController` | `/fruitbankdata/*` | Public data API endpoints; also implements `IFruitBankDataControllerServer` | + +--- + +## 7. Widget Zones + +The plugin registers widgets in: +- `PublicWidgetZones.ProductBoxAddinfoBefore` → `ProductAIWidgetViewComponent` +- `PublicWidgetZones.ProductDetailsBottom` → `ProductAIWidgetViewComponent` +- `AdminWidgetZones.ProductDetailsBlock` → `ProductAttributesViewComponent` +- `AdminWidgetZones.OrderDetailsBlock` → `OrderAttributesViewComponent` + +--- + +## 8. DI Registration Patterns (PluginNopStartup) + +Important overrides / replacements: +```csharp +// Replaces the default NopCommerce price calculator +services.Replace(ServiceDescriptor.Scoped()); + +// Overrides email order table rendering +services.Replace(ServiceDescriptor.Scoped()); + +// Overrides generic attribute service +services.AddScoped(); + +// Overrides order model and product model factories +services.AddScoped(); +services.AddScoped(); + +// Overrides WorkflowMessageService (order emails) +services.AddScoped(); +``` + +SignalR is configured with: +- MaximumReceiveMessageSize / StatefulReconnectBufferSize: 30 MB +- `DevAdminSignalRHub` on `/{FruitBankConstClient.DefaultHubName}` (WebSockets only) +- `LoggerSignalRHub` on `/{FruitBankConstClient.LoggerHubName}` + +--- + +## 9. Database / Data Layer + +Custom tables use **LinqToDB** (not EF Core) through wrapper DbTable classes registered in DI. Two DbContext wrappers: +- `FruitBankDbContext` – main plugin data +- `StockTakingDbContext` – stock taking workflow data + +Key custom tables: +| DbTable class | Purpose | +|---|---| +| `PartnerDbTable` | Business partners (wholesale customers) | +| `ShippingDbTable` | Shipping records | +| `ShippingDocumentDbTable` | Parsed shipping document metadata | +| `ShippingItemDbTable` | Line items from shipping documents | +| `ShippingDocumentToFilesDbTable` | Junction: document ↔ file | +| `FilesDbTable` | Generic file records (hash, compression flag, raw text) | +| `OrderDtoDbTable` / `OrderItemDtoDbTable` | Order DTO projections | +| `OrderItemPalletDbTable` / `ShippingItemPalletDbTable` / etc. | Pallet tracking for measurement workflow | +| `StockTakingDbTable` / `StockTakingItemDbTable` | Stock audit records | +| `StockQuantityHistoryDtoDbTable` | Stock movement history | +| `MeasuringItemPalletBaseDbTable` | Base pallet measuring data | + +**N+1 query prevention:** Always batch DB calls with `Task.WhenAll`. Never query per-item inside a loop. + +--- + +## 10. Localization + +All resource keys follow the prefix: `Plugins.Misc.FruitBankPlugin.*` + +- Keys are registered programmatically in `FruitBankPlugin.InstallAsync()` for **both EN and HU** +- XML locale files in `/Localization/`: `quickorder.en.xml`, `quickorder.hu.xml` +- When adding new keys: update **all three places** (InstallAsync + both XML files) +- Hungarian is the **primary** language; English is secondary +- Use `_localizationService.AddOrUpdateLocaleResourceAsync("key", "value", "HU")` pattern + +Common key prefixes: +- `Plugins.Misc.FruitBankPlugin.Menu.*` – navigation +- `Plugins.Misc.FruitBankPlugin.QuickOrder.*` – quick order page (extensive set) + +--- + +## 11. Quick Order Page (`/gyors-rendeles`) + +**Controller:** `QuickOrderController` +**View:** `/Views/QuickOrder/` +**CSS:** `/css/quick-order.css` (in plugin) + deployed to CarHaven theme + +Design system tokens (CarHaven theme): +```css +--theme-color: #2d7a3a /* green */ +--active-color: #f4a236 /* amber */ +--dark: #1a3c22 +--light-bg: #f5f7f2 +font-family: 'DM Sans' +border-radius: 8px +``` + +Product cards use full-width flex rows: `.product-card { flex-direction: row }` with `.pc-body` (left, grows) and `.pc-actions` (right, fixed). + +Navigation menu integration: +- CarHaven `TopMenu/Default.cshtml` has a `
  • ` for both desktop (`.notmobile`) and mobile (`.mobile`) menu blocks +- Guarded by `@if (Model.DisplayCustomerInfoMenuItem)` (login-gated) +- Menu item styled in `quick-order-menu.css` (amber, bold) included via `Head.cshtml` +- Uses `fa fa-bolt` icon + +Voice input: +- Records audio in browser, POSTs to `FruitBankAudioController` +- Whisper transcription with Hungarian vocabulary hints (partner names + produce terms) +- **Prompt character limit is 224** – use keyword extraction, not full company names +- Fallback: manual text search input + +--- + +## 12. Split Order Feature + +Admin page on order detail. Two modes selectable via radio buttons: + +| Mode | Behaviour | +|---|---| +| **Audit-based** | Available only when order has both "started" and "non-started" audit items. Audited items stay; non-audited items move to new order. | +| **Manual selection** | Always available (except for fully audited orders). Checkbox per item; user chooses what moves. | + +Split button is always enabled (except audited orders). Mode availability is communicated visually if a mode is disabled. + +**Critical lesson:** `TransactionSafeAsync` caused deadlocks because `TaskHelper.ToThreadPoolTask` creates async/await context switching in ASP.NET. The transaction wrapper was removed. Avoid wrapping split logic in `TransactionSafeAsync`. + +After split: inventory adjustments, order notes written, SignalR notification sent to admin clients. + +--- + +## 13. AI Admin Dashboard (`GetWelcomeMessageAsync`) + +Located in `CustomDashboardController`. Generates a structured OpenAI prompt containing: +- Store data summary +- Today's order totals +- Stock discrepancy summary (from stock taking audit) +- OpenWeatherMap weather data (real API key configured in settings) + +Patterns used: +- Typed C# records for data transfer +- `Task.WhenAll` for parallel DB calls +- Batched product history queries (no N+1) +- Bilingual (Hungarian/English) system prompt with JSON field guide for the AI +- `salesAdjustmentSum` – be careful not to double-count + +--- + +## 14. Shipping Document Processing + +AI-driven workflow for extracting partner + product data from uploaded PDFs/images: + +1. File uploaded → SHA256 hash calculated **before AI call** +2. Hash checked against DB → if duplicate, load existing data (skip AI, save API cost) +3. PDF converted to image(s) via `PdfToImageService` if needed +4. OpenAI vision API extracts structured product/partner data +5. Multi-stage matching: string search → historical shipping data → AI semantic match +6. UI shows visual matched/unmatched indicators + autocomplete for manual correction +7. `IsMeasurable` is **server-side only** – never expose in frontend forms + +--- + +## 15. NopCommerce 4.80 Gotchas + +| Issue | Correct approach | +|---|---| +| `ICustomerAttributeService` does not exist | Use direct `XDocument.Parse` on the XML stored in `GenericAttribute.Value` | +| `ParseAttributeValuesAsync` returns empty for free-text attributes | It's designed for predefined selection attributes (ID lookup). For free-text: parse XML directly: `......` | +| `TransactionSafeAsync` + async = deadlock | `TaskHelper.ToThreadPoolTask` inside it causes context switching deadlocks in ASP.NET; remove transaction wrapper for affected code | +| Email order table customization | Override `IMessageTokenProvider` with `FruitBankMessageTokenProvider` (already done); base class constructor has many parameters – pass all through exactly | +| `OrderPlaced.CustomerNotification` email template | Configured in NopCommerce admin under Content → Message Templates; code hook is `WorkflowMessageService.SendOrderPlacedCustomerNotificationAsync` | + +--- + +## 16. Theme (CarHaven) + +Path: `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven` + +Relevant files modified by this plugin's work: +- `TopMenu/Default.cshtml` – quick order nav item added +- `Head.cshtml` – includes `quick-order-menu.css` +- `css/quick-order-menu.css` – amber nav item styling + +--- + +## 17. External APIs / Credentials + +| Service | Usage | Notes | +|---|---|---| +| OpenAI | Chat completions (gpt-4o-mini / gpt-4o), Whisper transcription | API key in `FruitBankSettings.ApiKey` | +| OpenWeatherMap | Weather data for dashboard welcome message | Real API key confirmed in settings | +| Replicate | Image/audio AI models | Bearer token hardcoded in `PluginNopStartup` HTTP client registration | +| InnVoice | Hungarian accounting/invoicing system | REST API via `InnVoiceApiService` | + +--- + +## 18. Conventions & Patterns to Follow + +1. **Always bilingual** – every new locale resource key goes into InstallAsync (EN + HU) and both XML files. +2. **Mobile-first** for warehouse tools – large touch targets, step-by-step UX, pulse animations. +3. **Server-side business rules** – never expose `IsMeasurable` or similar computed flags in frontend forms. +4. **Batch DB calls** – use `Task.WhenAll`, never query inside a loop. +5. **Hash before AI** – always deduplicate files by SHA256 hash before calling any AI API. +6. **CarHaven design tokens** – use CSS variables (`--theme-color`, `--active-color`, `--dark`, `--light-bg`, `DM Sans` font) consistently. +7. **No `TransactionSafeAsync`** on code paths that use async/await through thread pool. +8. **Expect corrections** – Adam knows the codebase deeply; treat any correction as authoritative and apply it without re-litigating. + +--- + +*Last updated: 2026-03-18 | Maintained by: Claude (auto-generated from codebase + project chat history)* diff --git a/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs b/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs index 125a6bf..4458787 100644 --- a/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs +++ b/Nop.Plugin.Misc.AIPlugin/Services/CustomPriceCalculationService.cs @@ -26,6 +26,7 @@ public class CustomPriceCalculationService : PriceCalculationService private readonly IProductAttributeService _productAttributeService; private readonly ISpecificationAttributeService _specificationAttributeService; private readonly ILocalizationService _localizationService; + private readonly IStoreContext _storeContext; private ILogger _logger; public CustomPriceCalculationService( @@ -46,7 +47,8 @@ public class CustomPriceCalculationService : PriceCalculationService ILocalizationService localizationService, IStaticCacheManager cacheManager, IWorkContext workContext, - IEnumerable logWriters) + IEnumerable logWriters, + IStoreContext storeContext) : base(catalogSettings, currencySettings, categoryService, currencyService, customerService, discountService, manufacturerService, productAttributeParser, productService, cacheManager) @@ -58,6 +60,7 @@ public class CustomPriceCalculationService : PriceCalculationService _productAttributeService = productAttributeService; _specificationAttributeService = specificationAttributeService; _localizationService = localizationService; + _storeContext = storeContext; } public static decimal CalculateOrderItemFinalPrice(bool isMeasurable, decimal unitPrice, int quantity, double netWeight) @@ -135,9 +138,26 @@ public class CustomPriceCalculationService : PriceCalculationService _logger.Info($"order.OrderTotal({order.OrderTotal}) == prevOrderTotal({prevOrderTotal})"); order.OrderSubtotalInclTax = order.OrderTotal; - order.OrderSubtotalExclTax = order.OrderTotal; - order.OrderSubTotalDiscountInclTax = order.OrderTotal; - order.OrderSubTotalDiscountExclTax = order.OrderTotal; + order.OrderSubtotalExclTax = (order.OrderTotal / (decimal)1.27); + + //mivel csak csekkolunk, de nem adunk vissza semmilyen kedvezményt, így a subtotal discount értékek kiszámolááshoz meg kell hívni megint a calculate final price-t + decimal orderSubTotalDiscountInclTax = 0; + decimal orderSubTotalDiscountExclTax = 0; + var store = await _storeContext.GetCurrentStoreAsync(); + + foreach (var orderItem in orderItems) + { + var orderItemDto = orderItemDtosById[orderItem.Id]; + var product = await _dbContext.Products.GetByIdAsync(orderItem.ProductId); + var customer = await _dbContext.Customers.GetByIdAsync(order.CustomerId); + var itemPrice = await GetFinalPriceAsync(product, customer, store, 0, true, orderItemDto.Quantity); + orderSubTotalDiscountInclTax += itemPrice.appliedDiscountAmount; + orderSubTotalDiscountExclTax += itemPrice.appliedDiscountAmount / (decimal)1.27; + } + + + order.OrderSubTotalDiscountInclTax = orderSubTotalDiscountInclTax; + order.OrderSubTotalDiscountExclTax = orderSubTotalDiscountExclTax; await _dbContext.Orders.UpdateAsync(order, false); return true; diff --git a/Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml b/Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml new file mode 100644 index 0000000..5644530 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/Views/QuickOrder/Index.cshtml @@ -0,0 +1,566 @@ +@{ + Layout = "_Root"; + ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle").Text; +} + + + +
    + + +
    + +
    + + +
    + + +
    + + + + + + +
    + +

    @T("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts")

    +
    + + + +
    + + +
    + +
    + @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle") + 0 +
    + +
    + +

    @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1")
    @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2")

    +
    + + + + + + + +
    +
    +
    + +@Html.AntiForgeryToken() + +@* JS string bundle — Razor renders these once so JS never contains raw Hungarian *@ + + + diff --git a/Nop.Plugin.Misc.AIPlugin/css/quick-order.css b/Nop.Plugin.Misc.AIPlugin/css/quick-order.css new file mode 100644 index 0000000..a850bc9 --- /dev/null +++ b/Nop.Plugin.Misc.AIPlugin/css/quick-order.css @@ -0,0 +1,756 @@ +/* + * Quick Order Page — FruitBank / CarHaven Theme + * Design tokens inherited from themes/CarHaven/Content/css/styles.css :root + * --theme-color : #2d7a3a (forest green) + * --active-color: #f4a236 (amber / CTA) + * --dark : #1a3c22 (dark green) + * --light-bg : #f5f7f2 (off-white green tint) + * --text-primary: #2c2c2c + * --text-muted : #6b7c6e + * --accent-lime : #8cb63c + * --warm-bg : #faebd7 + * font : 'DM Sans', sans-serif + * radius : 8px + */ + +/* ───────────────────────────────────────────── + PAGE SHELL +───────────────────────────────────────────── */ +.quick-order-page { + width: 94%; + max-width: 1400px; + margin: 0 auto; + padding: 24px 0 60px; + font-family: 'DM Sans', sans-serif; + color: #2c2c2c; +} + +/* ───────────────────────────────────────────── + SEARCH BAR +───────────────────────────────────────────── */ +.qo-search-bar-wrapper { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(45, 122, 58, 0.08); + padding: 18px 20px; + margin-bottom: 24px; +} + +.search-input-group { + display: flex; + align-items: center; + gap: 0; +} + +/* Mic button */ +.mic-btn { + flex-shrink: 0; + width: 46px; + height: 46px; + border: 2px solid #2d7a3a; + background: #fff; + color: #2d7a3a; + border-radius: 8px 0 0 8px; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s, color 0.2s; + position: relative; +} + +.mic-btn:hover { + background: #2d7a3a; + color: #fff; +} + +.mic-btn-recording { + background: #1a3c22; + color: #f4a236; + border-color: #1a3c22; + animation: mic-pulse 1.4s ease-in-out infinite; +} + +@keyframes mic-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(244,162,54,.5); } + 50% { box-shadow: 0 0 0 8px rgba(244,162,54,0); } +} + +.mic-pulse { + display: none; +} + +/* Search text input */ +.qo-input { + flex: 1; + height: 46px; + border: 2px solid #dde8da; + border-left: none; + border-right: none; + border-radius: 0; + padding: 0 16px; + font-size: 15px; + font-family: 'DM Sans', sans-serif; + color: #2c2c2c; + outline: none; + transition: border-color 0.2s; +} + +.qo-input:focus { + border-color: #2d7a3a; + z-index: 1; +} + +.qo-input::placeholder { + color: #6b7c6e; +} + +/* Search button */ +.qo-search-btn { + flex-shrink: 0; + height: 46px; + padding: 0 22px; + background: #2d7a3a; + color: #fff; + border: 2px solid #2d7a3a; + border-radius: 0 8px 8px 0; + font-size: 14px; + font-family: 'DM Sans', sans-serif; + font-weight: 600; + letter-spacing: 0.3px; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.qo-search-btn:hover { + background: #1a3c22; + border-color: #1a3c22; +} + +/* Recording status bar */ +.recording-status-bar { + margin-top: 12px; + display: flex; + align-items: center; + gap: 14px; + background: #f5f7f2; + border: 1px solid #dde8da; + border-radius: 6px; + padding: 8px 14px; +} + +#statusText { + font-size: 13px; + color: #2d7a3a; + font-weight: 600; + min-width: 130px; + white-space: nowrap; +} + +.volume-bar-container { + flex: 1; + height: 6px; + background: #dde8da; + border-radius: 3px; + overflow: hidden; +} + +.volume-bar { + height: 100%; + width: 0; + border-radius: 3px; + transition: width 0.1s ease, background 0.2s; + background: #dde8da; +} +.volume-bar-low { background: #f4a236; } +.volume-bar-medium { background: #8cb63c; } +.volume-bar-high { background: #2d7a3a; } +.volume-bar-silent { background: #dde8da; } + +/* ───────────────────────────────────────────── + TWO-COLUMN LAYOUT +───────────────────────────────────────────── */ +.qo-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 24px; + align-items: start; +} + +/* ───────────────────────────────────────────── + PRODUCTS PANEL (LEFT) +───────────────────────────────────────────── */ + +/* "I heard" transcription card */ +.result-card { + background: #fff; + border: 1px solid #dde8da; + border-left: 4px solid #2d7a3a; + border-radius: 8px; + padding: 14px 18px; + margin-bottom: 16px; +} + +.result-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: #2d7a3a; + margin-bottom: 4px; +} + +.result-text { + font-size: 15px; + color: #2c2c2c; +} + +/* No results / empty */ +.no-results-card { + background: #fff; + border: 1px dashed #dde8da; + border-radius: 8px; + text-align: center; + padding: 40px 20px; + color: #6b7c6e; + font-size: 15px; +} + +.no-results-card .fa { + font-size: 28px; + color: #dde8da; + margin-bottom: 10px; + display: block; +} + +/* Loading state */ +.products-empty-state { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + text-align: center; + padding: 48px 20px; + color: #6b7c6e; +} + +.products-empty-state .fa { + font-size: 28px; + color: #2d7a3a; + margin-bottom: 10px; + display: block; +} + +/* Section header above product list */ +.matches-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7c6e; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 6px; +} + +.matches-label .fa { + color: #2d7a3a; + font-size: 14px; +} + +/* Group label (search results grouped by keyword) */ +.group-label { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #f4a236; + border-bottom: 1px solid #f5f7f2; + padding: 4px 0 8px; + margin: 12px 0 6px; + display: flex; + align-items: center; + gap: 6px; +} + +/* ───────────────────────────────────────────── + PRODUCT LIST — full-width rows +───────────────────────────────────────────── */ +.product-grid { + display: flex; + flex-direction: column; + gap: 6px; +} + +.product-card { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + padding: 10px 14px; + display: flex; + flex-direction: row; + align-items: center; + gap: 14px; + transition: box-shadow 0.18s, border-color 0.18s; +} + +.product-card:hover { + box-shadow: 0 3px 12px rgba(45, 122, 58, 0.10); + border-color: #2d7a3a; +} + +.product-card.has-warning { + border-left: 3px solid #f4a236; +} + +/* Body — grows, holds name + meta inline */ +.pc-body { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 14px; +} + +/* Product name */ +.pc-name { + font-size: 14px; + font-weight: 700; + color: #1a3c22; + line-height: 1.3; + display: flex; + align-items: flex-start; + gap: 5px; + flex: 1 1 200px; + min-width: 0; +} + +.pc-name .fa { + color: #8cb63c; + font-size: 12px; + margin-top: 2px; + flex-shrink: 0; +} + +/* Meta row — stock + price inline */ +.pc-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; +} + +.pc-stock { + font-size: 12px; + color: #6b7c6e; + background: #f5f7f2; + border-radius: 4px; + padding: 2px 8px; + white-space: nowrap; +} + +.pc-stock.stock-low { + background: #fff8ee; + color: #e8734a; +} + +.pm-price { + font-size: 13px; + font-weight: 700; + color: #2d7a3a; + white-space: nowrap; +} + +/* Badges */ +.stock-warning-badge { + font-size: 11px; + color: #e8734a; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.measurable-badge { + font-size: 11px; + background: #faebd7; + color: #e8734a; + border-radius: 4px; + padding: 2px 8px; + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +/* Actions — fixed width, right-aligned */ +.pc-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* Qty stepper */ +.qty-stepper { + display: flex; + align-items: center; + border: 1px solid #dde8da; + border-radius: 8px; + overflow: hidden; +} + +.qty-btn { + width: 34px; + height: 36px; + background: #f5f7f2; + border: none; + color: #2d7a3a; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s; + flex-shrink: 0; +} + +.qty-btn:hover { + background: #dde8da; +} + +.qty-input { + width: 48px; + height: 36px; + border: none; + border-left: 1px solid #dde8da; + border-right: 1px solid #dde8da; + text-align: center; + font-size: 14px; + font-weight: 700; + color: #1a3c22; + font-family: 'DM Sans', sans-serif; + -moz-appearance: textfield; +} + +.qty-input::-webkit-outer-spin-button, +.qty-input::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +/* Add to cart button */ +.pc-add-btn { + width: 36px; + height: 36px; + background: #2d7a3a; + color: #fff; + border: none; + border-radius: 8px; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: background 0.18s, transform 0.12s; +} + +.pc-add-btn:hover { + background: #1a3c22; + transform: scale(1.06); +} + +.pc-add-btn:disabled { + background: #dde8da; + cursor: default; + transform: none; +} + +.pc-add-btn.added { + background: #8cb63c; +} + +/* ───────────────────────────────────────────── + CART PANEL (RIGHT) +───────────────────────────────────────────── */ +.qo-cart-panel { + background: #fff; + border: 1px solid #dde8da; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(45, 122, 58, 0.06); + position: sticky; + top: 16px; + overflow: hidden; +} + +.qo-section-title { + background: #1a3c22; + color: #fff; + font-size: 15px; + font-weight: 700; + padding: 14px 18px; + display: flex; + align-items: center; + gap: 8px; + letter-spacing: 0.3px; +} + +.qo-section-title .fa { + color: #f4a236; + font-size: 17px; +} + +.cart-count-badge { + background: #f4a236; + color: #fff; + font-size: 11px; + font-weight: 700; + border-radius: 12px; + padding: 1px 7px; + margin-left: auto; + min-width: 24px; + text-align: center; +} + +.cart-empty { + padding: 36px 20px; + text-align: center; + color: #6b7c6e; +} + +.cart-empty .fa { + font-size: 30px; + color: #dde8da; + display: block; + margin-bottom: 10px; +} + +.cart-empty p { + font-size: 14px; + line-height: 1.5; +} + +.cart-items-list { + padding: 4px 0; + max-height: 400px; + overflow-y: auto; +} + +.cart-item { + padding: 11px 18px; + border-bottom: 1px solid #f5f7f2; + display: flex; + flex-direction: column; + gap: 4px; +} + +.cart-item:last-child { + border-bottom: none; +} + +.ci-name { + font-size: 14px; + font-weight: 600; + color: #1a3c22; + line-height: 1.3; +} + +.ci-details { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ci-qty { + font-size: 12px; + background: #f5f7f2; + color: #2d7a3a; + font-weight: 700; + border-radius: 4px; + padding: 1px 7px; +} + +.ci-price { + font-size: 12px; + color: #6b7c6e; +} + +.line-total { + font-size: 13px; + font-weight: 700; + color: #2d7a3a; + margin-left: auto; +} + +.measurable-badge-sm { + font-size: 12px; + color: #e8734a; + margin-left: auto; +} + +.cart-total-row { + border-top: 1px solid #dde8da; + padding: 14px 18px; + background: #f5f7f2; +} + +.cart-total-note { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 11px; + color: #6b7c6e; + line-height: 1.4; + margin-bottom: 10px; +} + +.cart-total-note .fa { + color: #f4a236; + margin-top: 1px; + flex-shrink: 0; +} + +.cart-total { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; + color: #2c2c2c; +} + +.cart-total strong { + font-size: 18px; + font-weight: 800; + color: #1a3c22; +} + +#cartActions { + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid #dde8da; +} + +.btn-checkout { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + background: #2d7a3a; + color: #fff !important; + border-radius: 8px; + font-size: 14px; + font-weight: 700; + text-decoration: none; + text-align: center; + transition: background 0.18s; + letter-spacing: 0.2px; +} + +.btn-checkout:hover { + background: #1a3c22; +} + +.btn-checkout .fa { + font-size: 16px; + color: #f4a236; +} + +.btn-view-cart { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 10px; + background: #f5f7f2; + color: #2d7a3a !important; + border: 1px solid #dde8da; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + text-decoration: none; + text-align: center; + transition: background 0.18s; +} + +.btn-view-cart:hover { + background: #dde8da; +} + +/* ───────────────────────────────────────────── + TOAST NOTIFICATION +───────────────────────────────────────────── */ +.qo-toast { + position: fixed; + bottom: 28px; + right: 24px; + background: #1a3c22; + color: #fff; + padding: 13px 20px; + border-radius: 8px; + font-size: 14px; + font-family: 'DM Sans', sans-serif; + box-shadow: 0 4px 20px rgba(26, 60, 34, 0.3); + z-index: 9999; + opacity: 0; + transform: translateY(12px); + transition: opacity 0.28s, transform 0.28s; + max-width: 320px; + border-left: 4px solid #f4a236; +} + +.qo-toast.show { + opacity: 1; + transform: translateY(0); +} + +.qo-toast .fa { + color: #8cb63c; + margin-right: 6px; +} + +/* ───────────────────────────────────────────── + RESPONSIVE +───────────────────────────────────────────── */ +@media (max-width: 960px) { + .qo-layout { + grid-template-columns: 1fr; + } + + .qo-cart-panel { + position: static; + } +} + +@media (max-width: 600px) { + .quick-order-page { + width: 100%; + padding: 12px 12px 40px; + } + + .product-card { + flex-wrap: wrap; + } + + .pc-body { + flex: 1 1 100%; + } + + .pc-actions { + width: 100%; + justify-content: flex-end; + } + + .qo-search-btn { + padding: 0 14px; + font-size: 13px; + } +}