nem ír ez lószart se... gyors rendelés, deisgn

This commit is contained in:
Adam 2026-03-18 15:15:50 +01:00
parent 000f1de2dd
commit 8e1b3f2a5d
13 changed files with 2918 additions and 44 deletions

View File

@ -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
@ -601,13 +603,15 @@ 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

View File

@ -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<IActionResult> Index()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Challenge();
return View("~/Plugins/Misc.FruitBankPlugin/Views/QuickOrder/Index.cshtml");
}
/// <summary>
/// Return all available products with prices (for initial page load)
/// </summary>
[HttpGet]
public async Task<IActionResult> 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<object>();
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}" });
}
}
/// <summary>
/// Parse a manually typed product list and return matching products with prices
/// </summary>
[HttpPost]
public async Task<IActionResult> 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}" });
}
}
/// <summary>
/// Transcribe voice audio (Hungarian) then parse and match products
/// </summary>
[HttpPost]
public async Task<IActionResult> 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}" });
}
}
/// <summary>
/// Add a product to the current customer's shopping cart and return the updated cart
/// </summary>
[HttpPost]
public async Task<IActionResult> 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}" });
}
}
/// <summary>
/// Return the current customer's cart as JSON (for cart panel refresh)
/// </summary>
[HttpGet]
public async Task<IActionResult> 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
/// <summary>Shorthand: get a localized QuickOrder resource string</summary>
private Task<string> L(string keySuffix)
=> _localizationService.GetResourceAsync(Prefix + keySuffix);
private async Task<string> 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<List<ParsedProduct>> 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<ParsedProduct>();
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ParsedProduct>>(jsonMatch.Value)
?? new List<ParsedProduct>();
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] JSON parse error: {ex.Message}");
return new List<ParsedProduct>();
}
}
private async Task<List<object>> EnrichProductData(
List<ParsedProduct> parsedProducts,
Nop.Core.Domain.Customers.Customer customer,
Nop.Core.Domain.Stores.Store store)
{
var enrichedProducts = new List<object>();
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<List<object>> 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<object>();
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
}
}

View File

@ -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 kódban felülírja 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", "Szállítmányok", "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<IList<string>>(new List<string> { 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,7 +238,6 @@ 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;
@ -146,7 +245,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
}
return null;
}
}
}

View File

@ -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<AddressAttribute, AddressAttributeValue> addressAttributeFormatter,
IAttributeFormatter<CustomerAttribute, CustomerAttributeValue> customerAttributeFormatter,
IAttributeFormatter<VendorAttribute, VendorAttributeValue> 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<Token> 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<string> 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(@"
<table cellspacing=""0"" cellpadding=""6"" border=""1"" style=""width:100%;border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px;"">
<thead>
<tr style=""background-color:#4a7c3f;color:#ffffff;"">
<th style=""text-align:left;padding:8px;"">Termék</th>
<th style=""text-align:center;padding:8px;"">Mennyiség</th>
<th style=""text-align:right;padding:8px;"">Egységár</th>
<th style=""text-align:right;padding:8px;"">Összesen</th>
</tr>
</thead>
<tbody>");
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($@"
<tr style=""background-color:{rowBg};"">
<td style=""padding:8px;"">{product.Name}</td>
<td style=""padding:8px;text-align:center;"">{item.Quantity}</td>
<td style=""padding:8px;text-align:right;"">{unitPrice}</td>
<td style=""padding:8px;text-align:right;"">Kalkuláció alatt, nagyságrendileg {approximatePrice}</td>
</tr>");
}
else
{
sb.AppendLine($@"
<tr style=""background-color:{rowBg};"">
<td style=""padding:8px;"">{product.Name}</td>
<td style=""padding:8px;text-align:center;"">{item.Quantity}</td>
<td style=""padding:8px;text-align:right;"">{unitPrice}</td>
<td style=""padding:8px;text-align:right;"">{lineTotal}</td>
</tr>");
}
}
var orderTotal = await _priceFormatter.FormatPriceAsync(
order.OrderTotal, true, currency, languageId, true);
if(itemDtos.Any(i => i.IsMeasurable))
{
sb.AppendLine($@"
<tr style=""background-color:#e8f0e5;font-weight:bold;"">
<td colspan=""3"" style=""padding:8px;text-align:right;"">Végösszeg:</td>
<td style=""padding:8px;text-align:right;"">Mérendő termék miatt kalkuláció alatt...</td>
</tr>");
}
else
{
sb.AppendLine($@"
<tr style=""background-color:#e8f0e5;font-weight:bold;"">
<td colspan=""3"" style=""padding:8px;text-align:right;"">Végösszeg:</td>
<td style=""padding:8px;text-align:right;"">{orderTotal}</td>
</tr>");
}
sb.AppendLine(" </tbody>\n</table>");
return sb.ToString();
}
}
}

View File

@ -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<IStockSignalREndpointServer, StockSignalREndpointServer>();
//services.AddScoped<CustomModelFactory, ICustomerModelFactory>();
services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
services.AddScoped<PriceCalculationService, CustomPriceCalculationService>();
//services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
//services.AddScoped<PriceCalculationService, CustomPriceCalculationService>();
services.Replace(
ServiceDescriptor.Scoped<IPriceCalculationService, CustomPriceCalculationService>()
);
//services.AddScoped<IMessageTokenProvider, FruitBankMessageTokenProvider>();
services.Replace(
ServiceDescriptor.Scoped<IMessageTokenProvider, FruitBankMessageTokenProvider>()
);
services.AddScoped<IConsumer<OrderPlacedEvent>, EventConsumer>();
services.AddScoped<IOrderMeasurementService, OrderMeasurementService>();
services.AddScoped<PendingMeasurementCheckoutFilter>();

View File

@ -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,6 +178,37 @@ 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" });
}
/// <summary>

View File

@ -0,0 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="English" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Quick Order page — Plugins.Misc.FruitBankPlugin.QuickOrder.*
Import: Admin > Configuration > Languages > [English] > Import resources
═══════════════════════════════════════════════════════════ -->
<!-- Page general -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle">
<Value><![CDATA[Quick Order]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel">
<Value><![CDATA[Quick Order]]></Value>
</LocaleResource>
<!-- Search bar -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
<Value><![CDATA[Start voice recording]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle">
<Value><![CDATA[Stop]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder">
<Value><![CDATA[Search for products (e.g. orange 100, apple 50) or use the microphone...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder">
<Value><![CDATA[Listening... (start speaking)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton">
<Value><![CDATA[Search]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus">
<Value><![CDATA[Listening...]]></Value>
</LocaleResource>
<!-- Product panel -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel">
<Value><![CDATA[I heard:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText">
<Value><![CDATA[No products found. Try a different search.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts">
<Value><![CDATA[Loading products...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel">
<Value><![CDATA[All products]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel">
<Value><![CDATA[Results]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint">
<Value><![CDATA[— set quantity, then add to cart:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge">
<Value><![CDATA[Requires weighing]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel">
<Value><![CDATA[Stock:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix">
<Value><![CDATA[Only]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix">
<Value><![CDATA[pcs available]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit">
<Value><![CDATA[pcs]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece">
<Value><![CDATA[Ft/pcs]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle">
<Value><![CDATA[Add to cart]]></Value>
</LocaleResource>
<!-- Cart panel -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle">
<Value><![CDATA[Cart]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1">
<Value><![CDATA[Your cart is empty.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2">
<Value><![CDATA[Search for products and add them.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote">
<Value><![CDATA[Prices for weighed items will be finalized after measurement.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal">
<Value><![CDATA[Estimated total:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton">
<Value><![CDATA[Proceed to checkout]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton">
<Value><![CDATA[View cart]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart">
<Value><![CDATA[added]]></Value>
</LocaleResource>
<!-- JavaScript voice recording strings -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported">
<Value><![CDATA[Your browser does not support audio recording.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError">
<Value><![CDATA[Could not access microphone: ]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied">
<Value><![CDATA[Please allow microphone access.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound">
<Value><![CDATA[No microphone found.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating">
<Value><![CDATA[Calibrating...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Processing">
<Value><![CDATA[Processing...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed">
<Value><![CDATA[Could not record audio. Please try again.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh">
<Value><![CDATA[Loud and clear]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking">
<Value><![CDATA[Speaking...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder">
<Value><![CDATA[Speak louder!]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Searching">
<Value><![CDATA[Searching...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts">
<Value><![CDATA[Please enter the products!]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError">
<Value><![CDATA[Error during search.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError">
<Value><![CDATA[Error processing audio.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError">
<Value><![CDATA[Error adding item to cart.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix">
<Value><![CDATA[Error: ]]></Value>
</LocaleResource>
<!-- Controller error messages (JSON responses) -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn">
<Value><![CDATA[Not logged in]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided">
<Value><![CDATA[No text provided]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified">
<Value><![CDATA[Could not identify products]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived">
<Value><![CDATA[No audio received]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed">
<Value><![CDATA[Speech recognition failed]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable">
<Value><![CDATA[Product not available]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity">
<Value><![CDATA[Invalid product or quantity]]></Value>
</LocaleResource>
</Language>

View File

@ -0,0 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Gyors rendelés oldal — Plugins.Misc.FruitBankPlugin.QuickOrder.*
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
═══════════════════════════════════════════════════════════ -->
<!-- Oldal általános -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle">
<Value><![CDATA[Gyors rendelés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel">
<Value><![CDATA[Gyors rendelés]]></Value>
</LocaleResource>
<!-- Keresősáv -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle">
<Value><![CDATA[Hangfelvétel indítása]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle">
<Value><![CDATA[Leállítás]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder">
<Value><![CDATA[Keress termékeket (pl. narancs 100, alma 50) vagy használd a mikrofont...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder">
<Value><![CDATA[Figyelés... (kezdj el beszélni)]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton">
<Value><![CDATA[Keresés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus">
<Value><![CDATA[Figyelés...]]></Value>
</LocaleResource>
<!-- Termék panel -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel">
<Value><![CDATA[Hallottam:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText">
<Value><![CDATA[Nem találtunk termékeket. Próbáljunk más keresést.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts">
<Value><![CDATA[Termékek betöltése...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel">
<Value><![CDATA[Összes termék]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel">
<Value><![CDATA[Találatok]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint">
<Value><![CDATA[— állítsd be a mennyiséget, majd add a kosárhoz:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge">
<Value><![CDATA[Súlymérést igényel]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel">
<Value><![CDATA[Készlet:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix">
<Value><![CDATA[Csak]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix">
<Value><![CDATA[db elérhető]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit">
<Value><![CDATA[db]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece">
<Value><![CDATA[Ft/db]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle">
<Value><![CDATA[Kosárba]]></Value>
</LocaleResource>
<!-- Kosár panel -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle">
<Value><![CDATA[Kosár]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1">
<Value><![CDATA[A kosár üres.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2">
<Value><![CDATA[Keress termékeket és add hozzá őket.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote">
<Value><![CDATA[A súlymérést igénylő tételeknél az ár a mérés után véglegesedik.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal">
<Value><![CDATA[Becsült összeg:]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton">
<Value><![CDATA[Tovább a pénztárhoz]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton">
<Value><![CDATA[Kosár megtekintése]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart">
<Value><![CDATA[hozzáadva]]></Value>
</LocaleResource>
<!-- JavaScript hangfelvétel szövegek -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported">
<Value><![CDATA[A böngésző nem támogatja a hangfelvételt.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError">
<Value><![CDATA[Nem sikerült a mikrofon elérése: ]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied">
<Value><![CDATA[Engedélyezd a mikrofon használatát.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound">
<Value><![CDATA[Nincs mikrofon csatlakoztatva.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating">
<Value><![CDATA[Kalibrálódás...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Processing">
<Value><![CDATA[Feldolgozás...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed">
<Value><![CDATA[Nem sikerült hangot rögzíteni. Kérem, próbálja újra.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh">
<Value><![CDATA[Hangos és érthető]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking">
<Value><![CDATA[Beszél...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder">
<Value><![CDATA[Hangosabban!]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.Searching">
<Value><![CDATA[Keresés...]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts">
<Value><![CDATA[Kérem, add meg a termékeket!]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError">
<Value><![CDATA[Hiba a keresés során.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError">
<Value><![CDATA[Hiba a hangfeldolgozás során.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError">
<Value><![CDATA[Hiba a kosárba helyezés során.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix">
<Value><![CDATA[Hiba: ]]></Value>
</LocaleResource>
<!-- Controller hibaüzenetek (JSON válaszok) -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn">
<Value><![CDATA[Nincs bejelentkezve]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided">
<Value><![CDATA[Nincs szöveg megadva]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified">
<Value><![CDATA[Nem sikerült termékeket azonosítani]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived">
<Value><![CDATA[Nem érkezett hangfájl]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed">
<Value><![CDATA[Nem sikerült a hangfelismerés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable">
<Value><![CDATA[A termék nem elérhető]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity">
<Value><![CDATA[Érvénytelen termék vagy mennyiség]]></Value>
</LocaleResource>
</Language>

View File

@ -10,6 +10,7 @@
<ItemGroup>
<None Remove="Areas\Admin\Views\Order\Edit.cshtml" />
<None Remove="css\quick-order.css" />
<None Remove="logo.jpg" />
<None Remove="plugin.json" />
<None Remove="Views\_ViewImports.cshtml" />
@ -65,6 +66,9 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="css\quick-order.css">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="logo.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@ -661,6 +665,9 @@
<None Update="Views\ProductAIWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\QuickOrder\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>

View File

@ -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<IPriceCalculationService, CustomPriceCalculationService>());
// Overrides email order table rendering
services.Replace(ServiceDescriptor.Scoped<IMessageTokenProvider, FruitBankMessageTokenProvider>());
// Overrides generic attribute service
services.AddScoped<IGenericAttributeService, GenericAttributeService>();
// Overrides order model and product model factories
services.AddScoped<IOrderModelFactory, CustomOrderModelFactory>();
services.AddScoped<IProductModelFactory, CustomProductModelFactory>();
// Overrides WorkflowMessageService (order emails)
services.AddScoped<IWorkflowMessageService, WorkflowMessageService>();
```
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 `<li class="quick-order-menu-item">` 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: `<Attributes><CustomerAttribute ID="1"><CustomerAttributeValue><Value>...</Value>...` |
| `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)*

View File

@ -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<IAcLogWriterBase> logWriters)
IEnumerable<IAcLogWriterBase> 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;

View File

@ -0,0 +1,566 @@
@{
Layout = "_Root";
ViewBag.Title = T("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle").Text;
}
<link rel="stylesheet" href="~/Plugins/Misc.FruitBankPlugin/css/quick-order.css" />
<div class="quick-order-page">
<!-- Full-width Search Bar -->
<div class="qo-search-bar-wrapper">
<div class="qo-search-bar">
<div class="search-input-group">
<button id="recordBtn" class="mic-btn" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle")">
<i class="fa fa-microphone"></i>
</button>
<button id="stopBtn" class="mic-btn mic-btn-recording" style="display:none;" title="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle")">
<i class="fa fa-stop"></i>
<span class="mic-pulse"></span>
</button>
<input type="text"
id="searchInput"
class="qo-input"
placeholder="@T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder")"
onkeypress="if(event.key==='Enter') submitTextSearch()">
<button class="qo-search-btn" onclick="submitTextSearch()">
<i class="fa fa-search"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton")
</button>
</div>
<div id="recordingStatus" class="recording-status-bar" style="display:none;">
<span id="statusText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus")</span>
<div class="volume-bar-container">
<div class="volume-bar volume-bar-silent"></div>
</div>
</div>
</div>
</div>
<!-- Two-column layout -->
<div class="qo-layout">
<!-- LEFT: Products -->
<div class="qo-products-panel">
<div id="transcribedCard" class="result-card" style="display:none;">
<div class="result-label"><i class="fa fa-microphone"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel")</div>
<div id="transcribedText" class="result-text"></div>
</div>
<div id="noResultsCard" class="no-results-card" style="display:none;">
<i class="fa fa-search"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText")</p>
</div>
<!-- Loading state -->
<div id="productsLoadingState" class="products-empty-state">
<i class="fa fa-spinner fa-spin"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts")</p>
</div>
<div id="productMatchesCard" style="display:none;">
<div class="matches-label">
<i class="fa fa-cubes"></i> <span id="matchesLabelText">@T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel")</span>
@T("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint")
</div>
<div id="productButtons" class="product-grid"></div>
</div>
</div>
<!-- RIGHT: Cart -->
<div class="qo-cart-panel">
<div class="qo-section-title">
<i class="fa fa-shopping-basket"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle")
<span id="cartItemCount" class="cart-count-badge">0</span>
</div>
<div id="cartEmptyState" class="cart-empty">
<i class="fa fa-shopping-basket"></i>
<p>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1")<br>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2")</p>
</div>
<div id="cartItemsList" class="cart-items-list" style="display:none;"></div>
<div id="cartTotalRow" class="cart-total-row" style="display:none;">
<div class="cart-total-note">
<i class="fa fa-info-circle"></i>
<small>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote")</small>
</div>
<div class="cart-total">
<span>@T("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal")</span>
<strong id="cartTotalAmount">0 Ft</strong>
</div>
</div>
<div id="cartActions" style="display:none;">
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-checkout">
<i class="fa fa-shopping-cart"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton")
</a>
<a href="@Url.Action("Cart", "ShoppingCart")" class="btn-view-cart">
<i class="fa fa-eye"></i> @T("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton")
</a>
</div>
</div>
</div>
</div>
@Html.AntiForgeryToken()
@* JS string bundle — Razor renders these once so JS never contains raw Hungarian *@
<script asp-location="Footer">
var qoStr = {
allProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel").Text))',
searchResults: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel").Text))',
searchPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder").Text))',
activeRecordingPlaceholder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder").Text))',
listeningStatus: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus").Text))',
browserNotSupported: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported").Text))',
micAccessError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError").Text))',
micPermissionDenied: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied").Text))',
micNotFound: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound").Text))',
calibrating: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating").Text))',
processing: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing").Text))',
recordingFailed: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed").Text))',
volumeHigh: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh").Text))',
volumeSpeaking: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking").Text))',
volumeLouder: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder").Text))',
searching: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching").Text))',
enterProducts: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts").Text))',
searchError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError").Text))',
audioError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError").Text))',
addToCartError: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError").Text))',
errorPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix").Text))',
measurableBadge: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge").Text))',
stockLabel: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel").Text))',
stockLimitedPrefix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix").Text))',
stockLimitedSuffix: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix").Text))',
pieceUnit: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit").Text))',
pricePerPiece: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece").Text))',
addToCartTitle: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle").Text))',
addedToCart: '@Html.Raw(JavaScriptEncoder.Default.Encode(T("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart").Text))'
};
</script>
<script asp-location="Footer">
var mediaRecorder = null;
var audioChunks = [];
var isRecording = false;
var audioContext = null;
var analyser = null;
var volumeCheckInterval = null;
var recordingStartTime = null;
var baselineNoiseLevel = -60;
var volumeHistory = [];
var VAD_CONFIG = {
silenceDuration: 1500,
minRecordingTime: 800,
volumeCheckInterval: 100,
calibrationTime: 500,
noiseGateOffset: 15,
volumeHistorySize: 10
};
$(document).ready(function () {
$('#recordBtn').click(startRecording);
$('#stopBtn').click(function () { stopRecording(false); });
loadCart();
loadAllProducts();
});
// ── Product list ──────────────────────────────────────────────────────────
function loadAllProducts() {
$('#transcribedCard').hide();
$('#noResultsCard').hide();
$('#productMatchesCard').hide();
$('#productsLoadingState').show();
$('#matchesLabelText').text(qoStr.allProducts);
$.ajax({
url: '@Url.Action("GetAllProducts", "QuickOrder")',
type: 'GET',
success: function (result) {
$('#productsLoadingState').hide();
if (result.success && result.products && result.products.length > 0) {
displayProductMatches(result.products);
} else {
$('#noResultsCard').show();
}
},
error: function () {
$('#productsLoadingState').hide();
$('#noResultsCard').show();
}
});
}
// ── Voice recording ───────────────────────────────────────────────────────
function getSupportedMimeType() {
var types = ['audio/webm', 'audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4'];
for (var i = 0; i < types.length; i++) {
if (MediaRecorder.isTypeSupported(types[i])) return types[i];
}
return 'audio/webm';
}
function startRecording() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert(qoStr.browserNotSupported);
return;
}
navigator.mediaDevices.getUserMedia({ audio: true })
.then(function (stream) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
audioContext.createMediaStreamSource(stream).connect(analyser);
analyser.fftSize = 512;
var mimeType = getSupportedMimeType();
mediaRecorder = new MediaRecorder(stream, { mimeType: mimeType });
audioChunks = [];
recordingStartTime = Date.now();
isRecording = true;
mediaRecorder.addEventListener('dataavailable', function (e) { audioChunks.push(e.data); });
mediaRecorder.addEventListener('stop', function () {
var blob = new Blob(audioChunks, { type: mimeType });
stream.getTracks().forEach(function (t) { t.stop(); });
if (audioContext) { audioContext.close(); audioContext = null; }
analyser = null;
isRecording = false;
if (blob.size === 0) {
alert(qoStr.recordingFailed);
resetRecordingUI();
return;
}
processAudio(blob, mimeType);
});
mediaRecorder.start();
$('#recordBtn').hide();
$('#stopBtn').show();
$('#searchInput').attr('placeholder', qoStr.activeRecordingPlaceholder);
showStatus(qoStr.activeRecordingPlaceholder);
startVAD();
})
.catch(function (err) {
var msg = qoStr.micAccessError;
if (err.name === 'NotAllowedError') msg += qoStr.micPermissionDenied;
else if (err.name === 'NotFoundError') msg += qoStr.micNotFound;
else msg += err.message;
alert(msg);
});
}
function startVAD() {
var bufferLength = analyser.frequencyBinCount;
var dataArray = new Uint8Array(bufferLength);
if (volumeCheckInterval) clearInterval(volumeCheckInterval);
var silentChecks = 0;
var silentNeeded = Math.ceil(VAD_CONFIG.silenceDuration / VAD_CONFIG.volumeCheckInterval);
var calibrated = false;
var calibSamples = [];
volumeHistory = [];
volumeCheckInterval = setInterval(function () {
if (!isRecording || !analyser) { clearInterval(volumeCheckInterval); return; }
analyser.getByteFrequencyData(dataArray);
var sum = 0;
for (var i = 0; i < bufferLength; i++) sum += dataArray[i];
var avg = sum / bufferLength;
var volume = 20 * Math.log10(avg / 255);
var elapsed = Date.now() - recordingStartTime;
if (!calibrated && elapsed < VAD_CONFIG.calibrationTime) {
calibSamples.push(volume);
updateVolumeBar(volume, false, qoStr.calibrating);
return;
}
if (!calibrated && calibSamples.length > 0) {
var total = 0;
for (var j = 0; j < calibSamples.length; j++) total += calibSamples[j];
baselineNoiseLevel = total / calibSamples.length;
calibrated = true;
}
volumeHistory.push(volume);
if (volumeHistory.length > VAD_CONFIG.volumeHistorySize) volumeHistory.shift();
var volSum = 0;
for (var k = 0; k < volumeHistory.length; k++) volSum += volumeHistory[k];
var avgVol = volSum / volumeHistory.length;
var threshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
updateVolumeBar(volume, true, null);
if (elapsed < VAD_CONFIG.minRecordingTime) return;
if (avgVol < threshold) {
silentChecks++;
if (silentChecks >= silentNeeded) { clearInterval(volumeCheckInterval); stopRecording(true); }
} else {
silentChecks = 0;
}
}, VAD_CONFIG.volumeCheckInterval);
}
function updateVolumeBar(volume, active, customMsg) {
var threshold = baselineNoiseLevel + VAD_CONFIG.noiseGateOffset;
var norm = Math.max(0, Math.min(100, ((volume - threshold + 10) / 40) * 100));
var text = customMsg || qoStr.listeningStatus;
var cls = 'volume-bar-silent';
if (active && !customMsg) {
if (norm > 60) { text = qoStr.volumeHigh; cls = 'volume-bar-high'; }
else if (norm > 30) { text = qoStr.volumeSpeaking; cls = 'volume-bar-medium'; }
else if (norm > 10) { text = qoStr.volumeLouder; cls = 'volume-bar-low'; }
else { text = qoStr.listeningStatus; }
}
$('#statusText').text(text);
$('#recordingStatus .volume-bar')
.removeClass('volume-bar-low volume-bar-medium volume-bar-high volume-bar-silent')
.addClass(cls).css('width', norm + '%');
}
function stopRecording(auto) {
if (volumeCheckInterval) { clearInterval(volumeCheckInterval); volumeCheckInterval = null; }
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
showStatus(qoStr.processing);
mediaRecorder.stop();
}
}
function processAudio(blob, mimeType) {
var formData = new FormData();
formData.append('audioFile', blob, 'recording.webm');
formData.append('__RequestVerificationToken', $('input[name="__RequestVerificationToken"]').val());
$.ajax({
url: '@Url.Action("TranscribeAndSearch", "QuickOrder")',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (result) { resetRecordingUI(); handleSearchResult(result); },
error: function (err) { resetRecordingUI(); alert(qoStr.audioError); console.error(err); }
});
}
function resetRecordingUI() {
$('#recordingStatus').hide();
$('#recordBtn').show();
$('#stopBtn').hide();
$('#searchInput').attr('placeholder', qoStr.searchPlaceholder);
}
function showStatus(msg) {
$('#statusText').text(msg);
$('#recordingStatus').show();
}
// ── Search ────────────────────────────────────────────────────────────────
function submitTextSearch() {
var text = $('#searchInput').val().trim();
if (!text) { alert(qoStr.enterProducts); return; }
showStatus(qoStr.searching);
$('#recordingStatus').show();
$.ajax({
url: '@Url.Action("SearchProducts", "QuickOrder")',
type: 'POST',
data: { text: text, __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
success: function (result) { $('#recordingStatus').hide(); handleSearchResult(result); },
error: function () { $('#recordingStatus').hide(); alert(qoStr.searchError); }
});
}
function handleSearchResult(result) {
$('#noResultsCard').hide();
$('#productMatchesCard').hide();
$('#transcribedCard').hide();
$('#productsLoadingState').hide();
if (!result.success) { alert(qoStr.errorPrefix + result.message); return; }
if (result.transcription) { $('#transcribedText').text(result.transcription); $('#transcribedCard').show(); }
if (!result.products || result.products.length === 0) { $('#noResultsCard').show(); return; }
$('#matchesLabelText').text(qoStr.searchResults);
displayProductMatches(result.products);
}
// ── Product cards ─────────────────────────────────────────────────────────
function displayProductMatches(products) {
var container = $('#productButtons').empty();
var grouped = {};
for (var i = 0; i < products.length; i++) {
var key = products[i].searchTerm || '';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(products[i]);
}
var keys = Object.keys(grouped);
var multiGroup = keys.length > 1 || (keys.length === 1 && keys[0] !== '');
for (var g = 0; g < keys.length; g++) {
var term = keys[g];
if (multiGroup && term) container.append('<div class="group-label"><i class="fa fa-tag"></i> ' + term + '</div>');
var group = grouped[term];
for (var p = 0; p < group.length; p++) {
(function (product) {
var isMeasurable = product.isMeasurable;
var isReduced = product.isQuantityReduced;
var maxQty = product.stockQuantity;
var defaultQty = product.quantity;
var priceHtml = isMeasurable
? '<span class="measurable-badge"><i class="fa fa-balance-scale"></i> ' + qoStr.measurableBadge + '</span>'
: '<span class="pm-price">' + formatFt(product.unitPrice) + ' ' + qoStr.pricePerPiece + '</span>';
var warningHtml = isReduced
? '<div class="stock-warning-badge"><i class="fa fa-exclamation-triangle"></i> ' + qoStr.stockLimitedPrefix + ' ' + maxQty + ' ' + qoStr.stockLimitedSuffix + '</div>'
: '';
var card = $('<div>').addClass('product-card' + (isReduced ? ' has-warning' : ''));
card.html(
'<div class="pc-body">' +
'<div class="pc-name"><i class="fa fa-cube"></i> ' + product.name + '</div>' +
warningHtml +
'<div class="pc-meta">' +
'<span class="pc-stock' + (maxQty < 50 ? ' stock-low' : '') + '">' + qoStr.stockLabel + ' ' + maxQty + ' ' + qoStr.pieceUnit + '</span>' +
priceHtml +
'</div>' +
'</div>' +
'<div class="pc-actions">' +
'<div class="qty-stepper">' +
'<button type="button" class="qty-btn qty-minus" tabindex="-1"><i class="fa fa-minus"></i></button>' +
'<input type="number" class="qty-input" value="' + defaultQty + '" min="1" max="' + maxQty + '">' +
'<button type="button" class="qty-btn qty-plus" tabindex="-1"><i class="fa fa-plus"></i></button>' +
'</div>' +
'<button type="button" class="pc-add-btn" title="' + qoStr.addToCartTitle + '">' +
'<i class="fa fa-cart-arrow-down"></i>' +
'</button>' +
'</div>'
);
card.find('.qty-minus').click(function () {
var inp = $(this).siblings('.qty-input');
var val = parseInt(inp.val()) || 1;
if (val > 1) inp.val(val - 1);
});
card.find('.qty-plus').click(function () {
var inp = $(this).siblings('.qty-input');
var val = parseInt(inp.val()) || 1;
if (val < maxQty) inp.val(val + 1);
});
card.find('.qty-input').on('change blur', function () {
var val = parseInt($(this).val()) || 1;
val = Math.max(1, Math.min(maxQty, val));
$(this).val(val);
});
card.find('.pc-add-btn').click(function () {
var qty = parseInt(card.find('.qty-input').val()) || 1;
addToCart(product.id, qty, product.name, $(this));
});
container.append(card);
})(group[p]);
}
}
$('#productMatchesCard').show();
}
// ── Cart ──────────────────────────────────────────────────────────────────
function addToCart(productId, quantity, name, btnEl) {
btnEl.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
$.ajax({
url: '@Url.Action("AddToCart", "QuickOrder")',
type: 'POST',
data: { productId: productId, quantity: quantity, __RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val() },
success: function (result) {
if (result.success) {
btnEl.html('<i class="fa fa-check"></i>').addClass('added');
renderCart(result.cartItems);
showCartToast(name, quantity);
setTimeout(function () {
$('#searchInput').val('');
$('#transcribedCard').hide();
loadAllProducts();
}, 700);
} else {
alert(qoStr.errorPrefix + result.message);
btnEl.prop('disabled', false).html('<i class="fa fa-cart-arrow-down"></i>');
}
},
error: function () {
alert(qoStr.addToCartError);
btnEl.prop('disabled', false).html('<i class="fa fa-cart-arrow-down"></i>');
}
});
}
function loadCart() {
$.ajax({
url: '@Url.Action("GetCartItems", "QuickOrder")',
type: 'GET',
success: function (result) { if (result.success) renderCart(result.cartItems); }
});
}
function renderCart(items) {
var list = $('#cartItemsList').empty();
var count = items.length;
$('#cartItemCount').text(count);
if (count === 0) {
$('#cartEmptyState').show();
$('#cartItemsList, #cartTotalRow, #cartActions').hide();
return;
}
$('#cartEmptyState').hide();
$('#cartItemsList, #cartTotalRow, #cartActions').show();
var estimatedTotal = 0;
var hasMeasurable = false;
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (item.isMeasurable) hasMeasurable = true;
var lineTotal = item.isMeasurable ? null : (item.unitPrice * item.quantity);
if (lineTotal) estimatedTotal += lineTotal;
var lineTotalHtml = item.isMeasurable
? '<span class="measurable-badge-sm"><i class="fa fa-balance-scale"></i></span>'
: '<strong class="line-total">' + formatFt(lineTotal) + ' Ft</strong>';
var priceHtml = item.isMeasurable ? '' : '<span class="ci-price">' + formatFt(item.unitPrice) + ' ' + qoStr.pricePerPiece + '</span>';
list.append(
'<div class="cart-item">' +
'<div class="ci-name">' + item.name + '</div>' +
'<div class="ci-details">' +
'<span class="ci-qty">' + item.quantity + ' ' + qoStr.pieceUnit + '</span>' +
priceHtml + lineTotalHtml +
'</div>' +
'</div>'
);
}
$('#cartTotalAmount').text(formatFt(estimatedTotal) + ' Ft');
if (hasMeasurable) $('#cartTotalRow .cart-total-note').show();
else $('#cartTotalRow .cart-total-note').hide();
}
function showCartToast(name, qty) {
var toast = $('<div class="qo-toast"><i class="fa fa-check-circle"></i> <strong>' + name + '</strong> (' + qty + ' ' + qoStr.pieceUnit + ') ' + qoStr.addedToCart + '</div>');
$('body').append(toast);
setTimeout(function () { toast.addClass('show'); }, 10);
setTimeout(function () { toast.removeClass('show'); setTimeout(function () { toast.remove(); }, 400); }, 2500);
}
function formatFt(val) {
if (val === null || val === undefined) return '-';
return Math.round(val).toLocaleString('hu-HU');
}
</script>

View File

@ -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;
}
}