nem ír ez lószart se... gyors rendelés, deisgn
This commit is contained in:
parent
000f1de2dd
commit
8e1b3f2a5d
|
|
@ -591,6 +591,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
||||||
throw new Exception($"{errorText}");
|
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)
|
if (orderProductItem.Price != product.Price)
|
||||||
{
|
{
|
||||||
//manual price change
|
//manual price change
|
||||||
|
|
@ -601,13 +603,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
|
||||||
unitPricesIncludeDiscounts = true;
|
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);
|
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 _orderService.InsertOrderItemAsync(orderItem);
|
||||||
await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id));
|
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 unitPriceInclTaxValue = priceCalculation.finalPrice;
|
||||||
|
|
||||||
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
|
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.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity;
|
||||||
order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity;
|
order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity;
|
||||||
|
|
||||||
order.OrderSubTotalDiscountInclTax += order.OrderSubtotalInclTax - orderItem.PriceInclTax;
|
var appliedDiscounts = priceCalculation.appliedDiscountAmount;
|
||||||
order.OrderSubTotalDiscountExclTax += order.OrderSubtotalExclTax - orderItem.PriceExclTax;
|
var totalDiscountInclTax = appliedDiscounts * orderProductItem.Quantity;
|
||||||
|
var totalDiscountExclTax = appliedDiscounts * orderProductItem.Quantity;
|
||||||
|
|
||||||
|
order.OrderSubTotalDiscountInclTax += totalDiscountInclTax;
|
||||||
|
order.OrderSubTotalDiscountExclTax += totalDiscountExclTax;
|
||||||
|
|
||||||
//order.OrderTax
|
//order.OrderTax
|
||||||
//order.TaxRates
|
//order.TaxRates
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
using FruitBank.Common.Server;
|
using FruitBank.Common.Server;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||||
|
|
@ -59,7 +58,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||||
//TODO: Add "IsMeasurable" product attribute - FruitBankConst.IsMeasurableAttributeName
|
//TODO: Add "IsMeasurable" product attribute - FruitBankConst.IsMeasurableAttributeName
|
||||||
//TODO: Add "NeedsToBeMeasured" product attribute if not exists
|
//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
|
// Default settings
|
||||||
var settings = new FruitBankSettings
|
var settings = new FruitBankSettings
|
||||||
|
|
@ -67,8 +66,132 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||||
ApiKey = string.Empty
|
ApiKey = string.Empty
|
||||||
};
|
};
|
||||||
await _settingService.SaveSettingAsync(settings);
|
await _settingService.SaveSettingAsync(settings);
|
||||||
|
|
||||||
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Shipment", "EN");
|
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();
|
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 });
|
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)
|
public async Task ManageSiteMapAsync(AdminMenuItem rootNode)
|
||||||
{
|
{
|
||||||
if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS))
|
if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string GetConfigurationPageUrl()
|
public override string GetConfigurationPageUrl()
|
||||||
|
|
@ -138,7 +238,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||||
{
|
{
|
||||||
return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null;
|
return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (widgetZone == AdminWidgetZones.OrderDetailsBlock)
|
else if (widgetZone == AdminWidgetZones.OrderDetailsBlock)
|
||||||
{
|
{
|
||||||
return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null;
|
return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null;
|
||||||
|
|
@ -146,7 +245,6 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Nop.Core.Domain.Orders;
|
using Nop.Core.Domain.Orders;
|
||||||
using Nop.Core.Infrastructure;
|
using Nop.Core.Infrastructure;
|
||||||
using Nop.Data;
|
using Nop.Data;
|
||||||
|
|
@ -90,8 +91,15 @@ public class PluginNopStartup : INopStartup
|
||||||
services.AddScoped<IStockSignalREndpointServer, StockSignalREndpointServer>();
|
services.AddScoped<IStockSignalREndpointServer, StockSignalREndpointServer>();
|
||||||
|
|
||||||
//services.AddScoped<CustomModelFactory, ICustomerModelFactory>();
|
//services.AddScoped<CustomModelFactory, ICustomerModelFactory>();
|
||||||
services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
|
//services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
|
||||||
services.AddScoped<PriceCalculationService, 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<IConsumer<OrderPlacedEvent>, EventConsumer>();
|
||||||
services.AddScoped<IOrderMeasurementService, OrderMeasurementService>();
|
services.AddScoped<IOrderMeasurementService, OrderMeasurementService>();
|
||||||
services.AddScoped<PendingMeasurementCheckoutFilter>();
|
services.AddScoped<PendingMeasurementCheckoutFilter>();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ public class RouteProvider : IRouteProvider
|
||||||
name: "Plugin.FruitBank.Admin.Order.List",
|
name: "Plugin.FruitBank.Admin.Order.List",
|
||||||
pattern: "Admin/Order/List",
|
pattern: "Admin/Order/List",
|
||||||
defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN }
|
defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN }
|
||||||
//constraints: new { area = AreaNames.ADMIN }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
endpointRouteBuilder.MapControllerRoute(
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
|
@ -39,14 +38,12 @@ public class RouteProvider : IRouteProvider
|
||||||
name: "Plugin.FruitBank.Admin.Order.Test",
|
name: "Plugin.FruitBank.Admin.Order.Test",
|
||||||
pattern: "Admin/Order/Test",
|
pattern: "Admin/Order/Test",
|
||||||
defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN }
|
defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN }
|
||||||
//constraints: new { area = AreaNames.ADMIN }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
endpointRouteBuilder.MapControllerRoute(
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
name: "Plugin.FruitBank.Admin.Index",
|
name: "Plugin.FruitBank.Admin.Index",
|
||||||
pattern: "Admin",
|
pattern: "Admin",
|
||||||
defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN }
|
defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN }
|
||||||
//constraints: new { area = AreaNames.ADMIN }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
endpointRouteBuilder.MapControllerRoute(
|
endpointRouteBuilder.MapControllerRoute(
|
||||||
|
|
@ -181,6 +178,37 @@ public class RouteProvider : IRouteProvider
|
||||||
name: "Plugin.FruitBank.Admin.ExtractText",
|
name: "Plugin.FruitBank.Admin.ExtractText",
|
||||||
pattern: "Admin/ExtractText",
|
pattern: "Admin/ExtractText",
|
||||||
defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN });
|
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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Areas\Admin\Views\Order\Edit.cshtml" />
|
<None Remove="Areas\Admin\Views\Order\Edit.cshtml" />
|
||||||
|
<None Remove="css\quick-order.css" />
|
||||||
<None Remove="logo.jpg" />
|
<None Remove="logo.jpg" />
|
||||||
<None Remove="plugin.json" />
|
<None Remove="plugin.json" />
|
||||||
<None Remove="Views\_ViewImports.cshtml" />
|
<None Remove="Views\_ViewImports.cshtml" />
|
||||||
|
|
@ -65,6 +66,9 @@
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="css\quick-order.css">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<Content Include="logo.jpg">
|
<Content Include="logo.jpg">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
|
@ -661,6 +665,9 @@
|
||||||
<None Update="Views\ProductAIWidget.cshtml">
|
<None Update="Views\ProductAIWidget.cshtml">
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<None Update="Views\QuickOrder\Index.cshtml">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -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)*
|
||||||
|
|
@ -26,6 +26,7 @@ public class CustomPriceCalculationService : PriceCalculationService
|
||||||
private readonly IProductAttributeService _productAttributeService;
|
private readonly IProductAttributeService _productAttributeService;
|
||||||
private readonly ISpecificationAttributeService _specificationAttributeService;
|
private readonly ISpecificationAttributeService _specificationAttributeService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly IStoreContext _storeContext;
|
||||||
private ILogger _logger;
|
private ILogger _logger;
|
||||||
|
|
||||||
public CustomPriceCalculationService(
|
public CustomPriceCalculationService(
|
||||||
|
|
@ -46,7 +47,8 @@ public class CustomPriceCalculationService : PriceCalculationService
|
||||||
ILocalizationService localizationService,
|
ILocalizationService localizationService,
|
||||||
IStaticCacheManager cacheManager,
|
IStaticCacheManager cacheManager,
|
||||||
IWorkContext workContext,
|
IWorkContext workContext,
|
||||||
IEnumerable<IAcLogWriterBase> logWriters)
|
IEnumerable<IAcLogWriterBase> logWriters,
|
||||||
|
IStoreContext storeContext)
|
||||||
: base(catalogSettings, currencySettings, categoryService, currencyService, customerService, discountService, manufacturerService,
|
: base(catalogSettings, currencySettings, categoryService, currencyService, customerService, discountService, manufacturerService,
|
||||||
productAttributeParser, productService,
|
productAttributeParser, productService,
|
||||||
cacheManager)
|
cacheManager)
|
||||||
|
|
@ -58,6 +60,7 @@ public class CustomPriceCalculationService : PriceCalculationService
|
||||||
_productAttributeService = productAttributeService;
|
_productAttributeService = productAttributeService;
|
||||||
_specificationAttributeService = specificationAttributeService;
|
_specificationAttributeService = specificationAttributeService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_storeContext = storeContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static decimal CalculateOrderItemFinalPrice(bool isMeasurable, decimal unitPrice, int quantity, double netWeight)
|
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})");
|
_logger.Info($"order.OrderTotal({order.OrderTotal}) == prevOrderTotal({prevOrderTotal})");
|
||||||
|
|
||||||
order.OrderSubtotalInclTax = order.OrderTotal;
|
order.OrderSubtotalInclTax = order.OrderTotal;
|
||||||
order.OrderSubtotalExclTax = order.OrderTotal;
|
order.OrderSubtotalExclTax = (order.OrderTotal / (decimal)1.27);
|
||||||
order.OrderSubTotalDiscountInclTax = order.OrderTotal;
|
|
||||||
order.OrderSubTotalDiscountExclTax = order.OrderTotal;
|
//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);
|
await _dbContext.Orders.UpdateAsync(order, false);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue