Compare commits

..

2 Commits

Author SHA1 Message Date
Adam 51f546caec CustomerCredit, new order 2026-03-27 17:14:40 +01:00
Adam 8e1b3f2a5d nem ír ez lószart se... gyors rendelés, deisgn 2026-03-18 15:15:50 +01:00
34 changed files with 5253 additions and 82 deletions

View File

@ -591,6 +591,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
throw new Exception($"{errorText}");
}
//itt vajon elég ez a vizsgálat, vagy a priceCalculationService.GetFinalPriceAsync-al kéne lekérni a végső árat és azt összehasonlítani? - A.
//ha kedvezménye is van, de manuálisan is le van csökkentve az ár, akkor a kedvezményt látja a rendszer, és azt kellene összevetni a bejövő árral... - A.
if (orderProductItem.Price != product.Price)
{
//manual price change
@ -600,14 +602,16 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
unitPricesIncludeDiscounts = true;
}
//itt ha includeDiscounts van, akkor már a beírt ár megy be?
var orderItem = await CreateOrderItem(product, order, orderProductItem, isMeasurable, unitPricesIncludeDiscounts, customer, store);
_logger.Detail($"Adding order item: ProductId: {orderItem.ProductId}, Quantity: {orderItem.Quantity}, UnitPriceInclTax: {orderItem.UnitPriceInclTax}, UnitPriceExclTax: {orderItem.UnitPriceExclTax}, PriceInclTax: {orderItem.PriceInclTax}, PriceExclTax: {orderItem.PriceExclTax}");
await _orderService.InsertOrderItemAsync(orderItem);
await _productService.AdjustInventoryAsync(product, -orderItem.Quantity, orderItem.AttributesXml, string.Format(await _localizationService.GetResourceAsync("Admin.StockQuantityHistory.Messages.PlaceOrder"), order.Id));
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: false);
var priceCalculation = await _priceCalculationService.GetFinalPriceAsync(product, customer, store, includeDiscounts: true);
var unitPriceInclTaxValue = priceCalculation.finalPrice;
var (unitPriceExclTaxValue, _) = await _taxService.GetProductPriceAsync(product, unitPriceInclTaxValue, false, customer);
@ -615,8 +619,12 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
order.OrderSubtotalInclTax += unitPriceInclTaxValue * orderItem.Quantity;
order.OrderSubtotalExclTax += unitPriceExclTaxValue * orderItem.Quantity;
order.OrderSubTotalDiscountInclTax += order.OrderSubtotalInclTax - orderItem.PriceInclTax;
order.OrderSubTotalDiscountExclTax += order.OrderSubtotalExclTax - orderItem.PriceExclTax;
var appliedDiscounts = priceCalculation.appliedDiscountAmount;
var totalDiscountInclTax = appliedDiscounts * orderProductItem.Quantity;
var totalDiscountExclTax = appliedDiscounts * orderProductItem.Quantity;
order.OrderSubTotalDiscountInclTax += totalDiscountInclTax;
order.OrderSubTotalDiscountExclTax += totalDiscountExclTax;
//order.OrderTax
//order.TaxRates
@ -1751,7 +1759,235 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
}
}
// ═══════════════════════════════════════════════════════════════════
// FruitBank Order Grid new server-side DataTables endpoint
// ═══════════════════════════════════════════════════════════════════
/// <summary>
/// Returns the new FruitBank order list view (replaces the default NopCommerce grid).
/// </summary>
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
public async Task<IActionResult> NewList(
List<int> orderStatuses = null,
List<int> paymentStatuses = null,
List<int> shippingStatuses = null)
{
var model = await _orderModelFactory.PrepareOrderSearchModelAsync(new OrderSearchModelExtended
{
OrderStatusIds = orderStatuses,
PaymentStatusIds = paymentStatuses,
ShippingStatusIds = shippingStatuses,
Length = 50,
AvailablePageSizes = "20,50,100,500",
SortColumn = "Id",
SortColumnDirection = "desc",
});
model.SetGridSort("Id", "desc");
model.SetGridPageSize(50, "20,50,100,500");
return View(
"~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/Order/FruitBankOrderList.cshtml",
model);
}
/// <summary>
/// DataTables server-side endpoint for the FruitBank order grid.
/// Handles NopCommerce base filters + FruitBank-specific filters + per-column search + sort + pagination.
/// </summary>
[HttpPost]
[CheckPermission(StandardPermission.Orders.ORDERS_VIEW)]
public async Task<IActionResult> FruitBankOrderList()
{
// ── 1. Parse DataTables protocol params ────────────────────────
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 50 : Math.Min(length, 500);
// Sort column
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "Id";
// Per-column search values keyed by column data-field name
var colSearch = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int ci = 0; Request.Form.ContainsKey($"columns[{ci}][data]"); ci++)
{
var cData = Request.Form[$"columns[{ci}][data]"].FirstOrDefault();
var cVal = Request.Form[$"columns[{ci}][search][value]"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(cData) && !string.IsNullOrWhiteSpace(cVal))
colSearch[cData] = cVal.Trim();
}
// ── 2. Parse custom filter params ─────────────────────────────
DateTime? startDate = null, endDate = null;
if (DateTime.TryParse(Request.Form["StartDate"].FirstOrDefault(), out var sd)) startDate = sd;
if (DateTime.TryParse(Request.Form["EndDate"].FirstOrDefault(), out var ed)) endDate = ed;
var orderStatusIds = Request.Form["OrderStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var paymentStatusIds = Request.Form["PaymentStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var shippingStatusIds = Request.Form["ShippingStatusIds"]
.Where(v => int.TryParse(v, out _)).Select(int.Parse).Where(id => id > 0).ToList();
var billingCompany = Request.Form["BillingCompany"].FirstOrDefault(); // holds customer ID (string)
bool? isMeasurableFilter = null;
var imStr = Request.Form["IsMeasurable"].FirstOrDefault();
if (imStr == "true") isMeasurableFilter = true;
if (imStr == "false") isMeasurableFilter = false;
bool? hasInnvoiceFilter = null;
var hiStr = Request.Form["HasInnvoiceTechId"].FirstOrDefault();
if (hiStr == "true") hasInnvoiceFilter = true;
if (hiStr == "false") hasInnvoiceFilter = false;
// ── 3. Fetch data via factory (applies NopCommerce base filters)
// We ask for a large page so all matching records come back in one shot;
// FruitBank-specific filtering + pagination happen below in-process.
var searchModel = new OrderSearchModelExtended
{
StartDate = startDate,
EndDate = endDate,
OrderStatusIds = orderStatusIds.Any() ? orderStatusIds : null,
PaymentStatusIds = paymentStatusIds.Any() ? paymentStatusIds : null,
ShippingStatusIds = shippingStatusIds.Any() ? shippingStatusIds : null,
BillingCompany = billingCompany,
SortColumn = "Id",
SortColumnDirection = "desc"
};
// SetGridPageSize is the proper NopCommerce way to override Page/PageSize
searchModel.SetGridPageSize(5000, "5000");
OrderListModelExtended orderListModel;
try
{
orderListModel = await _orderModelFactory.PrepareOrderListModelExtendedAsync(searchModel);
}
catch (Exception ex)
{
_logger.Error($"FruitBankOrderList factory error: {ex.Message}", ex);
return Json(new { draw, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
}
var rows = orderListModel.Data?.ToList() ?? new List<OrderModelExtended>();
int total = orderListModel.RecordsTotal;
// ── 4. Map to lightweight DTO ──────────────────────────────────
var dtos = rows.Select(o => new Nop.Plugin.Misc.FruitBankPlugin.Models.Orders.FruitBankOrderRowDto
{
Id = o.Id,
CustomOrderNumber = o.CustomOrderNumber,
CustomerCompany = o.CustomerCompany,
CustomerId = o.CustomerId,
InnvoiceTechId = o.InnvoiceTechId,
IsAllOrderItemAvgWeightValid = o.IsAllOrderItemAvgWeightValid,
IsMeasurable = o.IsMeasurable,
MeasuringStatus = (int)o.MeasuringStatus,
MeasuringStatusString = o.MeasuringStatusString,
DateOfReceipt = o.DateOfReceipt,
OrderStatusId = o.OrderStatusId,
OrderStatus = o.OrderStatus,
PaymentStatusId = o.PaymentStatusId,
PaymentStatus = o.PaymentStatus,
ShippingStatusId = o.ShippingStatusId,
ShippingStatus = o.ShippingStatus,
StoreName = o.StoreName,
CreatedOn = o.CreatedOn,
OrderTotal = o.OrderTotal
}).ToList();
// ── 5. Apply FruitBank-specific top-level filters ──────────────
if (isMeasurableFilter.HasValue)
dtos = dtos.Where(o => o.IsMeasurable == isMeasurableFilter.Value).ToList();
if (hasInnvoiceFilter.HasValue)
dtos = hasInnvoiceFilter.Value
? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList();
// ── 6. Apply per-column search ─────────────────────────────────
foreach (var (col, val) in colSearch)
{
dtos = col.ToLowerInvariant() switch
{
"customordernumber" => dtos.Where(o => o.CustomOrderNumber?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
"customercompany" => dtos.Where(o => o.CustomerCompany?.Contains(val, StringComparison.OrdinalIgnoreCase) == true).ToList(),
"orderstatusid" => int.TryParse(val, out int osId) ? dtos.Where(o => o.OrderStatusId == osId).ToList() : dtos,
"measuringstatus" => int.TryParse(val, out int msId) ? dtos.Where(o => o.MeasuringStatus == msId).ToList() : dtos,
"ismeasurable" => bool.TryParse(val, out bool bm) ? dtos.Where(o => o.IsMeasurable == bm).ToList() : dtos,
// InnVoice column sends 'has' or 'none' strings
"innvoicetechid" => val == "has" ? dtos.Where(o => !string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: val == "none" ? dtos.Where(o => string.IsNullOrEmpty(o.InnvoiceTechId)).ToList()
: dtos,
_ => dtos
};
}
int recordsFiltered = dtos.Count;
// ── 7. Sort ────────────────────────────────────────────────────
bool asc = sortDir == "asc";
dtos = sortColName.ToLowerInvariant() switch
{
"id" => asc ? dtos.OrderBy(o => o.Id).ToList() : dtos.OrderByDescending(o => o.Id).ToList(),
"customordernumber" => asc ? dtos.OrderBy(o => o.CustomOrderNumber).ToList() : dtos.OrderByDescending(o => o.CustomOrderNumber).ToList(),
"customercompany" => asc ? dtos.OrderBy(o => o.CustomerCompany).ToList() : dtos.OrderByDescending(o => o.CustomerCompany).ToList(),
"dateofreceipt" => asc ? dtos.OrderBy(o => o.DateOfReceipt ?? DateTime.MinValue).ToList() : dtos.OrderByDescending(o => o.DateOfReceipt ?? DateTime.MinValue).ToList(),
"createdon" => asc ? dtos.OrderBy(o => o.CreatedOn).ToList() : dtos.OrderByDescending(o => o.CreatedOn).ToList(),
"orderstatusid" => asc ? dtos.OrderBy(o => o.OrderStatusId).ToList() : dtos.OrderByDescending(o => o.OrderStatusId).ToList(),
"measuringstatus" => asc ? dtos.OrderBy(o => o.MeasuringStatus).ToList() : dtos.OrderByDescending(o => o.MeasuringStatus).ToList(),
_ => dtos.OrderByDescending(o => o.Id).ToList()
};
// ── 8. Paginate ────────────────────────────────────────────────
var page = dtos.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal = total, recordsFiltered, data = page });
}
/// <summary>
/// Inline-edit save endpoint. Currently supports DateOfReceipt.
/// </summary>
[HttpPost]
[CheckPermission(StandardPermission.Orders.ORDERS_CREATE_EDIT_DELETE)]
public async Task<IActionResult> UpdateOrderField(int orderId, string field, string value)
{
try
{
var order = await _orderService.GetOrderByIdAsync(orderId);
if (order == null)
return Json(new { success = false, error = "Rendelés nem található" });
switch (field?.ToUpperInvariant())
{
case "DATEOFRECEIPT":
if (string.IsNullOrWhiteSpace(value))
{
await _genericAttributeService.SaveAttributeAsync<DateTime?>(order, "DateOfReceipt", null);
return Json(new { success = true, displayValue = (string)null });
}
if (DateTime.TryParse(value, out var newDate))
{
await _genericAttributeService.SaveAttributeAsync(order, "DateOfReceipt", newDate);
return Json(new { success = true, displayValue = newDate.ToString("yyyy. MM. dd. HH:mm") });
}
return Json(new { success = false, error = "Érvénytelen dátum formátum" });
default:
return Json(new { success = false, error = $"Ismeretlen mező: {field}" });
}
}
catch (Exception ex)
{
_logger.Error($"UpdateOrderField error orderId={orderId} field={field}: {ex.Message}", ex);
return Json(new { success = false, error = ex.Message });
}
}
}
}

View File

@ -0,0 +1,285 @@
using FruitBank.Common.Entities;
using Microsoft.AspNetCore.Mvc;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Services.Security;
using Nop.Web.Framework;
using Nop.Web.Framework.Controllers;
using Nop.Web.Framework.Mvc.Filters;
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers;
[AuthorizeAdmin]
[Area(AreaNames.ADMIN)]
public class CustomerCreditController : BasePluginController
{
private readonly ICustomerCreditService _customerCreditService;
private readonly ICustomerService _customerService;
private readonly IRepository<Order> _orderRepository;
private readonly IRepository<Customer> _customerRepository;
private readonly CustomerCreditDbTable _customerCreditDbTable;
private readonly IPermissionService _permissionService;
private readonly ILocalizationService _localizationService;
public CustomerCreditController(
ICustomerCreditService customerCreditService,
ICustomerService customerService,
IRepository<Order> orderRepository,
IRepository<Customer> customerRepository,
CustomerCreditDbTable customerCreditDbTable,
IPermissionService permissionService,
ILocalizationService localizationService)
{
_customerCreditService = customerCreditService;
_customerService = customerService;
_orderRepository = orderRepository;
_customerRepository = customerRepository;
_customerCreditDbTable = customerCreditDbTable;
_permissionService = permissionService;
_localizationService = localizationService;
}
// ── LIST PAGE ─────────────────────────────────────────────────────────────
[HttpGet]
[Route("Admin/CustomerCredit/List")]
public async Task<IActionResult> List()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/List.cshtml");
}
// ── DATATABLES SERVER-SIDE ENDPOINT ──────────────────────────────────────
[HttpPost]
[Route("Admin/CustomerCredit/CustomerCreditList")]
public async Task<IActionResult> CustomerCreditList()
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { draw = 1, recordsTotal = 0, recordsFiltered = 0, data = Array.Empty<object>() });
_ = int.TryParse(Request.Form["draw"].FirstOrDefault(), out int draw); draw = Math.Max(draw, 1);
_ = int.TryParse(Request.Form["start"].FirstOrDefault(), out int start); start = Math.Max(start, 0);
_ = int.TryParse(Request.Form["length"].FirstOrDefault(), out int length); length = length < 1 ? 25 : Math.Min(length, 500);
_ = int.TryParse(Request.Form["order[0][column]"].FirstOrDefault(), out int sortColIdx);
var sortDir = Request.Form["order[0][dir]"].FirstOrDefault() ?? "desc";
var sortColName = Request.Form[$"columns[{sortColIdx}][data]"].FirstOrDefault() ?? "OutstandingBalance";
var globalSearch = Request.Form["search[value]"].FirstOrDefault()?.Trim() ?? "";
// 1. Customers — single query
var customers = await _customerRepository.Table
.Where(c => !c.Deleted && c.Active && c.Email != null)
.Select(c => new { c.Id, c.Email, c.FirstName, c.LastName })
.ToListAsync();
// 2. Credit records — single query
var credits = await _customerCreditDbTable.GetAll().ToListAsync();
var creditByCustomer = credits.ToDictionary(x => x.CustomerId);
// 3. Outstanding balances — single grouped query, no N+1
var outstandingByCustomer = await _orderRepository.Table
.Where(o =>
o.OrderStatusId != (int)OrderStatus.Cancelled &&
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
.GroupBy(o => o.CustomerId)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => (decimal?)o.OrderTotal) ?? 0m })
.ToListAsync();
var outstandingDict = outstandingByCustomer.ToDictionary(x => x.CustomerId, x => x.Total);
// 4. Build rows
var rows = customers.Select(c =>
{
creditByCustomer.TryGetValue(c.Id, out var credit);
outstandingDict.TryGetValue(c.Id, out var outstanding);
var hasLimit = credit != null;
var remaining = hasLimit ? credit!.CreditLimit - outstanding : (decimal?)null;
return new CustomerCreditListRow
{
CustomerId = c.Id,
CustomerEmail = c.Email ?? string.Empty,
CustomerName = $"{c.FirstName} {c.LastName}".Trim(),
HasCreditLimit = hasLimit,
CreditLimit = credit?.CreditLimit ?? 0m,
OutstandingBalance = outstanding,
RemainingCredit = remaining,
Comment = credit?.Comment
};
}).ToList();
int recordsTotal = rows.Count;
// 5. Global search
if (!string.IsNullOrWhiteSpace(globalSearch))
{
rows = rows.Where(r =>
r.CustomerName.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
r.CustomerEmail.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ||
(r.Comment?.Contains(globalSearch, StringComparison.OrdinalIgnoreCase) ?? false)
).ToList();
}
int recordsFiltered = rows.Count;
// 6. Sort
bool asc = sortDir == "asc";
rows = sortColName.ToLowerInvariant() switch
{
"customername" => asc ? rows.OrderBy(r => r.CustomerName).ToList() : rows.OrderByDescending(r => r.CustomerName).ToList(),
"customeremail" => asc ? rows.OrderBy(r => r.CustomerEmail).ToList() : rows.OrderByDescending(r => r.CustomerEmail).ToList(),
"creditlimit" => asc ? rows.OrderBy(r => r.CreditLimit).ToList() : rows.OrderByDescending(r => r.CreditLimit).ToList(),
"outstandingbalance" => asc ? rows.OrderBy(r => r.OutstandingBalance).ToList() : rows.OrderByDescending(r => r.OutstandingBalance).ToList(),
"remainingcredit" => asc ? rows.OrderBy(r => r.RemainingCredit ?? decimal.MaxValue).ToList() : rows.OrderByDescending(r => r.RemainingCredit ?? decimal.MinValue).ToList(),
_ => rows.OrderByDescending(r => r.OutstandingBalance).ToList()
};
// 7. Paginate
var page = rows.Skip(start).Take(length).ToList();
return Json(new { draw, recordsTotal, recordsFiltered, data = page });
}
// ── INLINE EDIT: CREDIT LIMIT ─────────────────────────────────────────────
[HttpPost]
[Route("Admin/CustomerCredit/UpdateCreditLimit")]
public async Task<IActionResult> UpdateCreditLimit(int customerId, string? creditLimit, bool removeLimit, string? comment)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, error = "Access denied" });
try
{
var existing = await _customerCreditService.GetByCustomerIdAsync(customerId);
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
// Empty input or explicit removeLimit flag → delete record = unlimited
if (removeLimit || string.IsNullOrWhiteSpace(creditLimit))
{
if (existing != null)
await _customerCreditService.DeleteAsync(existing);
return Json(new
{
success = true,
hasLimit = false,
creditLimit = (decimal?)null,
outstanding,
remaining = (decimal?)null
});
}
// Parse the value (JS sends invariant decimal)
if (!decimal.TryParse(creditLimit,
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture,
out var limit) || limit < 0)
return Json(new { success = false, error = "Érvénytelen összeg" });
var entity = existing ?? new CustomerCredit { CustomerId = customerId };
entity.CreditLimit = limit;
if (comment != null) entity.Comment = comment;
await _customerCreditService.SaveAsync(entity);
return Json(new
{
success = true,
hasLimit = true,
creditLimit = limit,
outstanding,
remaining = limit - outstanding
});
}
catch (Exception ex)
{
return Json(new { success = false, error = ex.Message });
}
}
// ── DETAILS ───────────────────────────────────────────────────────────────
[HttpGet]
[Route("Admin/CustomerCredit/Details/{customerId:int}")]
public async Task<IActionResult> Details(int customerId)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
var customer = await _customerService.GetCustomerByIdAsync(customerId);
if (customer == null)
return NotFound();
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
var unpaidOrders = await _orderRepository.Table
.Where(o =>
o.CustomerId == customerId &&
o.OrderStatusId != (int)OrderStatus.Cancelled &&
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
.OrderByDescending(o => o.CreatedOnUtc)
.ToListAsync();
var model = new CustomerCreditModel
{
CustomerId = customerId,
CustomerEmail = customer.Email,
CustomerName = $"{customer.FirstName} {customer.LastName}".Trim(),
CreditId = credit?.Id ?? 0,
CreditLimit = credit?.CreditLimit ?? 0m,
Comment = credit?.Comment,
OutstandingBalance = outstanding,
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
HasCreditLimit = credit != null,
UnpaidOrders = unpaidOrders.Select(o => new CustomerCreditOrderRow
{
OrderId = o.Id,
OrderTotal = o.OrderTotal,
CreatedOnUtc = o.CreatedOnUtc,
OrderStatus = o.OrderStatus.ToString(),
PaymentStatus = o.PaymentStatus.ToString()
}).ToList()
};
return View("~/Plugins/Misc.FruitBankPlugin/Areas/Admin/Views/CustomerCredit/Details.cshtml", model);
}
// ── SAVE (from Details page) ──────────────────────────────────────────────
[HttpPost]
[Route("Admin/CustomerCredit/Save")]
public async Task<IActionResult> Save(CustomerCreditModel model)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return AccessDeniedView();
if (!ModelState.IsValid)
return RedirectToAction("Details", new { customerId = model.CustomerId });
var entity = model.CreditId > 0
? await _customerCreditService.GetByCustomerIdAsync(model.CustomerId) ?? new CustomerCredit()
: new CustomerCredit();
entity.CustomerId = model.CustomerId;
entity.CreditLimit = model.CreditLimit;
entity.Comment = model.Comment;
await _customerCreditService.SaveAsync(entity);
return RedirectToAction("Details", new { customerId = model.CustomerId });
}
}

View File

@ -0,0 +1,13 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
public class CustomerCreditListRow
{
public int CustomerId { get; set; }
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public bool HasCreditLimit { get; set; }
public decimal CreditLimit { get; set; }
public decimal OutstandingBalance { get; set; }
public decimal? RemainingCredit { get; set; }
public string? Comment { get; set; }
}

View File

@ -0,0 +1,30 @@
namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models;
public class CustomerCreditModel
{
public int CustomerId { get; set; }
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
// Credit record
public int CreditId { get; set; }
public decimal CreditLimit { get; set; }
public string? Comment { get; set; }
public bool HasCreditLimit { get; set; }
// Calculated
public decimal OutstandingBalance { get; set; }
public decimal? RemainingCredit { get; set; }
// Unpaid orders table
public List<CustomerCreditOrderRow> UnpaidOrders { get; set; } = new();
}
public class CustomerCreditOrderRow
{
public int OrderId { get; set; }
public decimal OrderTotal { get; set; }
public DateTime CreatedOnUtc { get; set; }
public string OrderStatus { get; set; } = string.Empty;
public string PaymentStatus { get; set; } = string.Empty;
}

View File

@ -0,0 +1,151 @@
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.CustomerCreditModel
@using Nop.Web.Framework.UI
@{
// Layout = "_FruitBankAdminLayout";
var remaining = Model.RemainingCredit;
var statusClass = !Model.HasCreditLimit ? "status-unlimited"
: remaining <= 0 ? "status-blocked"
: remaining < Model.CreditLimit * 0.2m ? "status-warning"
: "status-ok";
}
<style>
.credit-summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.credit-card { flex: 1; min-width: 160px; background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem 1.25rem; }
.credit-card .label { font-size: 0.78rem; color: #666; margin-bottom: .3rem; }
.credit-card .value { font-size: 1.4rem; font-weight: 700; }
.status-ok .value { color: #2d7a3a; }
.status-warning .value { color: #f4a236; }
.status-blocked .value { color: #c0392b; }
.status-unlimited .value { color: #555; }
.back-link { margin-bottom: 1rem; display: inline-block; }
</style>
<a class="back-link" href="/Admin/Customer/Edit/@Model.CustomerId">
<i class="fa fa-arrow-left"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer")
</a>
<div class="content-header clearfix">
<h1 class="pull-left">
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle") — @Model.CustomerName (@Model.CustomerEmail)
</h1>
</div>
@* ── Summary cards ── *@
<div class="credit-summary">
<div class="credit-card">
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</div>
<div class="value">@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : "—")</div>
</div>
<div class="credit-card">
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</div>
<div class="value">@Model.OutstandingBalance.ToString("N0") Ft</div>
</div>
<div class="credit-card @statusClass">
<div class="label">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</div>
<div class="value">
@if (!Model.HasCreditLimit)
{
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
}
else
{
@remaining!.Value.ToString("N0")
<span style="font-size:.9rem">Ft</span>
}
</div>
</div>
</div>
@* ── Edit form ── *@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")</h3>
</div>
<div class="panel-body">
<form asp-action="Save" asp-controller="CustomerCredit" asp-area="Admin" method="post">
<input type="hidden" name="CustomerId" value="@Model.CustomerId" />
<input type="hidden" name="CreditId" value="@Model.CreditId" />
<div class="form-group row">
<div class="col-md-3 col-form-label">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
</div>
<div class="col-md-9">
<input type="number" name="CreditLimit" value="@Model.CreditLimit" min="0" step="1000" class="form-control" style="max-width:240px" />
<small class="form-text text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint")</small>
</div>
</div>
<div class="form-group row">
<div class="col-md-3 col-form-label">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
</div>
<div class="col-md-9">
<textarea name="Comment" class="form-control" rows="3" style="max-width:480px">@Model.Comment</textarea>
</div>
</div>
<div class="form-group row">
<div class="col-md-9 offset-md-3">
<button type="submit" class="btn btn-primary">
<i class="fa fa-save"></i> @T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save")
</button>
</div>
</div>
</form>
</div>
</div>
@* ── Unpaid orders table ── *@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle") (@Model.UnpaidOrders.Count)</h3>
</div>
<div class="panel-body">
@if (!Model.UnpaidOrders.Any())
{
<p class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders")</p>
}
else
{
<table class="table table-bordered table-hover">
<thead>
<tr>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus")</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var o in Model.UnpaidOrders)
{
<tr>
<td>#@o.OrderId</td>
<td>@o.CreatedOnUtc.ToLocalTime().ToString("yyyy.MM.dd HH:mm")</td>
<td><strong>@o.OrderTotal.ToString("N0") Ft</strong></td>
<td>@o.OrderStatus</td>
<td>@o.PaymentStatus</td>
<td>
<a href="/Admin/Order/Edit/@o.OrderId" class="btn btn-xs btn-default" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2"><strong>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total")</strong></td>
<td><strong>@Model.OutstandingBalance.ToString("N0") Ft</strong></td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
}
</div>
</div>

View File

@ -0,0 +1,200 @@
@{
ViewBag.PageTitle = "Hitelkeretek";
NopHtml.SetActiveMenuItemSystemName("CustomerCredit.List");
}
<div class="content-header clearfix">
<h1 class="float-left">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")</h1>
</div>
<section class="content">
<div class="container-fluid">
<div class="card card-default">
<div class="card-body p-0">
@Html.AntiForgeryToken()
<table id="cc-grid" class="table table-bordered table-hover m-0" style="width:100%">
<thead>
<tr>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail")</th>
<th title="Kattintásra szerkeszthető — törléshez hagyd üresen">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit") <small class="text-muted">✏️</small></th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</th>
<th>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</th>
<th></th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</section>
<style>
/* ── Editable credit limit cell ─────────────────────────────── */
#cc-grid tbody td.cc-editable {
cursor: pointer;
}
#cc-grid tbody td.cc-editable:hover {
background-color: #fff8e1;
}
#cc-grid tbody td.cc-editable input[type="number"] {
width: 130px;
font-size: 13px;
padding: 2px 6px;
border: 1px solid #80bdff;
border-radius: 3px;
}
/* ── Status colours ─────────────────────────────────────────── */
.cc-remaining-ok { color: #2d7a3a; font-weight: 600; }
.cc-remaining-warning { color: #e67e22; font-weight: 600; }
.cc-remaining-blocked { color: #c0392b; font-weight: 600; }
.cc-remaining-none { color: #888; }
/* ── Stripe + hover ─────────────────────────────────────────── */
#cc-grid tbody tr:nth-child(even) { background-color: #f9f9f9; }
#cc-grid tbody tr:hover { background-color: #eaf2ff; }
</style>
<script>
$(function () {
var _token = $('input[name="__RequestVerificationToken"]').val();
function fmt(val) {
if (val == null) return '—';
return Number(val).toLocaleString('hu-HU') + ' Ft';
}
function renderRemaining(row) {
if (!row.HasCreditLimit) return '<span class="cc-remaining-none">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
var r = row.RemainingCredit;
var cls = r <= 0 ? 'cc-remaining-blocked'
: r < row.CreditLimit * 0.2 ? 'cc-remaining-warning'
: 'cc-remaining-ok';
return '<span class="' + cls + '">' + fmt(r) + '</span>';
}
function renderCreditLimit(row) {
if (!row.HasCreditLimit) return '<span class="text-muted">@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text</span>';
return fmt(row.CreditLimit);
}
var table = $('#cc-grid').DataTable({
serverSide : true,
processing : true,
orderCellsTop: true,
pageLength : 25,
lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]],
order : [[3, 'desc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START_ _END_ / _TOTAL_ ügyfél',
infoEmpty : '0 ügyfél',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs találat',
zeroRecords : 'Nincs találat'
},
ajax: {
url : '/Admin/CustomerCredit/CustomerCreditList',
type: 'POST',
data: function (d) {
d.__RequestVerificationToken = _token;
},
error: function (xhr) {
console.error('CustomerCreditList error:', xhr.status, xhr.responseText);
}
},
columns: [
/* 0 */ { data: 'CustomerName', name: 'CustomerName' },
/* 1 */ { data: 'CustomerEmail', name: 'CustomerEmail' },
/* 2 */ { data: 'CreditLimit', name: 'CreditLimit', className: 'cc-editable text-right',
render: function (d, t, row) { return renderCreditLimit(row); } },
/* 3 */ { data: 'OutstandingBalance', name: 'OutstandingBalance', className: 'text-right',
render: function (d) {
var color = d > 0 ? 'color:#c0392b;font-weight:600' : '';
return '<span style="' + color + '">' + fmt(d) + '</span>';
}},
/* 4 */ { data: 'RemainingCredit', name: 'RemainingCredit',
render: function (d, t, row) { return renderRemaining(row); } },
/* 5 */ { data: 'Comment', name: 'Comment', orderable: false,
render: function (d) { return d ? '<span class="text-muted">' + d + '</span>' : ''; } },
/* 6 */ { data: 'CustomerId', name: null, orderable: false, searchable: false, width: '80px', className: 'text-center',
render: function (d) {
return '<a href="/Admin/CustomerCredit/Details/' + d + '" class="btn btn-default btn-xs" title="Részletek"><i class="fas fa-edit"></i></a>' +
' <a href="/Admin/Customer/Edit/' + d + '" class="btn btn-default btn-xs" title="Ügyfél szerkesztése"><i class="fas fa-user"></i></a>';
}}
]
});
/* ── Inline editing: CreditLimit ─────────────────────────────── */
$(document).on('click', '#cc-grid tbody td.cc-editable', function () {
var $td = $(this);
if ($td.find('input').length) return;
var $row = $td.closest('tr');
var rowData = table.row($row).data();
if (!rowData) return;
var savedHtml = $td.html();
var current = rowData.HasCreditLimit ? rowData.CreditLimit : '';
var $inp = $('<input type="number" min="0" step="1000" placeholder="Korlátlan (törléshez hagyd üresen)">')
.val(current)
.css({ width: '180px', fontSize: '13px' });
$td.html('').append($inp);
$inp.focus().select();
function restore() { $td.html(savedHtml); }
function persist() {
var raw = $inp.val().trim();
var removeLimit = raw === ''; // empty = remove limit → unlimited
var newVal = removeLimit ? null : parseFloat(raw);
// If a number was typed but is invalid or negative, cancel
if (!removeLimit && (isNaN(newVal) || newVal < 0)) { restore(); return; }
// No change: still has limit and same value
if (!removeLimit && rowData.HasCreditLimit && newVal === rowData.CreditLimit) { restore(); return; }
// No change: was already unlimited and still wants unlimited
if (removeLimit && !rowData.HasCreditLimit) { restore(); return; }
$.ajax({
url : '/Admin/CustomerCredit/UpdateCreditLimit',
type : 'POST',
data : {
__RequestVerificationToken : _token,
customerId : rowData.CustomerId,
creditLimit : removeLimit ? '' : newVal, // empty string signals "remove"
removeLimit : removeLimit,
comment : rowData.Comment || ''
},
success: function (res) {
if (res.success) {
rowData.CreditLimit = res.creditLimit;
rowData.OutstandingBalance = res.outstanding;
rowData.RemainingCredit = res.remaining;
rowData.HasCreditLimit = res.hasLimit;
table.row($row).data(rowData).invalidate().draw(false);
} else {
restore();
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
}
},
error: function () { restore(); }
});
}
$inp.on('blur', function () { persist(); });
$inp.on('keydown', function (e) {
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
});
});
});
</script>

View File

@ -0,0 +1,634 @@
@model Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Models.Order.OrderSearchModelExtended
@using FruitBank.Common.Interfaces
@using Nop.Services.Stores
@using Nop.Web.Areas.Admin.Components
@using Nop.Web.Areas.Admin.Models.Orders
@using Nop.Web.Framework.Infrastructure
@inject IStoreService storeService
@{
// Layout = "~/Areas/Admin/Views/Shared/_LayoutAdmin.cshtml";
ViewBag.PageTitle = "FruitBank Rendelések";
NopHtml.SetActiveMenuItemSystemName("Orders");
}
@* ── Action buttons ─────────────────────────────────────────────── *@
<form id="fb-header-form" asp-controller="Order" asp-action="List" method="post">
<div class="content-header clearfix">
<h1 class="float-left">Rendelések</h1>
<div class="float-right">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#create-order-window">
<i class="fas fa-plus"></i> @T("Admin.Common.AddNew")
</button>
<div class="btn-group">
<button type="button" class="btn btn-success"><i class="fas fa-download"></i> @T("Admin.Common.Export")</button>
<button type="button" class="btn btn-success dropdown-toggle dropdown-icon" data-toggle="dropdown"><span class="sr-only">&nbsp;</span></button>
<ul class="dropdown-menu" role="menu">
<li class="dropdown-item"><button asp-action="ExportXml" type="submit" name="exportxml-all"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.All")</button></li>
<li class="dropdown-item"><button type="button" id="exportxml-selected"><i class="far fa-file-code"></i> @T("Admin.Common.ExportToXml.Selected")</button></li>
<li class="dropdown-divider"></li>
<li class="dropdown-item"><button asp-action="ExportExcel" type="submit" name="exportexcel-all"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.All")</button></li>
<li class="dropdown-item"><button type="button" id="exportexcel-selected"><i class="far fa-file-excel"></i> @T("Admin.Common.ExportToExcel.Selected")</button></li>
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-info"><i class="far fa-file-pdf"></i> @T("Admin.Orders.PdfInvoices")</button>
<button type="button" class="btn btn-info dropdown-toggle dropdown-icon" data-toggle="dropdown"><span class="sr-only">&nbsp;</span></button>
<ul class="dropdown-menu" role="menu">
<li class="dropdown-item"><button asp-action="PdfInvoice" type="submit" name="pdf-invoice-all">@T("Admin.Orders.PdfInvoices.All")</button></li>
<li class="dropdown-item"><button type="button" id="pdf-invoice-selected">@T("Admin.Orders.PdfInvoices.Selected")</button></li>
</ul>
</div>
</div>
</div>
</form>
<section class="content">
<div class="container-fluid">
@* ── Filter Panel ─────────────────────────────────────────────── *@
<div class="card card-default card-search mb-2">
<div class="card-body py-2">
<div class="row align-items-end">
@* Date from *@
<div class="col-md-2">
<div class="form-group mb-1">
<nop-label asp-for="StartDate" />
<nop-editor asp-for="StartDate" />
</div>
</div>
@* Date to *@
<div class="col-md-2">
<div class="form-group mb-1">
<nop-label asp-for="EndDate" />
<nop-editor asp-for="EndDate" />
</div>
</div>
@* Partner autocomplete → stores customer ID in hidden *@
<div class="col-md-4">
<div class="form-group mb-1">
<label class="col-form-label">Partner</label>
<div class="input-group">
<input type="text" id="fb-company-display" autocomplete="off" class="form-control" placeholder="Cég neve..." />
<div class="input-group-append">
<button type="button" id="fb-company-clear" class="btn btn-outline-secondary" style="display:none">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<input asp-for="BillingCompany" type="hidden" id="BillingCompany" />
</div>
</div>
@* Go to order by number *@
<div class="col-md-3">
<div class="form-group mb-1">
<nop-label asp-for="GoDirectlyToCustomOrderNumber" />
<div class="input-group">
<nop-editor asp-for="GoDirectlyToCustomOrderNumber" />
<div class="input-group-append">
<button type="button" id="go-to-order-by-number" class="btn btn-info">
@T("Admin.Common.Go")
</button>
</div>
</div>
</div>
</div>
@* Search button *@
<div class="col-md-1">
<div class="form-group mb-1">
<label class="col-form-label">&nbsp;</label>
<button type="button" id="fb-search-btn" class="btn btn-primary btn-block">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@* ── Grid ─────────────────────────────────────────────────────── *@
<div class="card card-default">
<div class="card-body p-0">
@* Anti-forgery token for AJAX POSTs *@
@Html.AntiForgeryToken()
<table id="fb-orders-grid" class="table table-bordered table-hover m-0" style="width:100%">
<thead>
<tr>
<th><input type="checkbox" id="fb-check-all" title="Összes kijelölése"></th>
<th>Rendelés #</th>
<th>Partner</th>
<th>InnVoice</th>
<th>Súly</th>
<th>Mérhető</th>
<th>Mérés</th>
<th title="Kattintásra szerkeszthető">Átvétel <small class="text-muted">✏️</small></th>
<th>Státusz</th>
<th>Fizetés</th>
<th>Szállítás</th>
<th>Létrehozva</th>
<th>Összeg</th>
<th></th>
</tr>
</thead>
</table>
</div>
<div id="fb-totals-row" class="card-footer py-2" style="display:none">
<div id="fb-totals-content" class="small text-muted"></div>
</div>
</div>
</div>
</section>
@* ── Export selected XML ──────────────────────────────────────── *@
<form asp-controller="Order" asp-action="ExportXmlSelected" method="post" id="export-xml-selected-form">
<input type="hidden" id="export-xml-ids" name="selectedIds" value="" />
</form>
@* ── Export selected Excel ────────────────────────────────────── *@
<form asp-controller="Order" asp-action="ExportExcelSelected" method="post" id="export-excel-selected-form">
<input type="hidden" id="export-excel-ids" name="selectedIds" value="" />
</form>
@* ── PDF selected ───────────────────────────────────────────────── *@
<form asp-controller="Order" asp-action="PdfInvoiceSelected" method="post" id="pdf-invoice-selected-form">
<input type="hidden" id="pdf-invoice-ids" name="selectedIds" value="" />
</form>
@* ── Create Order Modal ─────────────────────────────────────────── *@
<div id="create-order-window" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">@T("Admin.Orders.AddNew")</h4>
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
</div>
<form asp-controller="CustomOrder" asp-action="Create" method="post" id="create-order-form">
<div class="form-horizontal">
<div class="modal-body">
<div class="form-group row">
<div class="col-md-3"><label class="col-form-label">@T("Admin.Orders.Fields.Customer")</label></div>
<div class="col-md-9">
<input type="text" id="create-order-customer-search" autocomplete="off" class="form-control" placeholder="Ügyfél neve vagy email..." />
<span id="create-order-customer-name" class="mt-1 d-block"></span>
<input type="hidden" id="create-order-customer-id" name="customerId" />
<span class="field-validation-error" id="create-order-customer-error" style="display:none">Kérjük válasszon ügyfelet</span>
</div>
</div>
<div class="form-group row" id="create-product-search-section" style="display:none">
<div class="col-md-3"><label class="col-form-label">@T("Admin.Orders.Fields.Product")</label></div>
<div class="col-md-9">
<input type="text" id="create-order-product-search" autocomplete="off" class="form-control" placeholder="Termék neve vagy SKU..." />
</div>
</div>
<div id="create-selected-products-section" style="display:none">
<table class="table table-sm table-bordered" id="create-products-table">
<thead><tr><th>Termék</th><th style="width:100px">Menny.</th><th style="width:120px">Egységár</th><th style="width:40px"></th></tr></thead>
<tbody id="create-products-body"></tbody>
</table>
</div>
<input type="hidden" id="create-order-products-json" name="orderProductsJson" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">@T("Admin.Common.Cancel")</button>
<button type="submit" class="btn btn-primary">@T("Admin.Common.Create")</button>
</div>
</div>
</form>
</div>
</div>
</div>
<style>
/* ── Column filter row ──────────────────────────────────────── */
#fb-orders-grid thead tr.fb-filter-row th {
padding: 3px 4px;
background-color: #f4f6f9;
border-bottom: 2px solid #dee2e6;
}
#fb-orders-grid thead tr.fb-filter-row input,
#fb-orders-grid thead tr.fb-filter-row select {
width: 100%;
height: 26px;
padding: 1px 4px;
font-size: 11px;
border: 1px solid #ced4da;
border-radius: 3px;
background: #fff;
}
#fb-orders-grid thead tr.fb-filter-row input:focus,
#fb-orders-grid thead tr.fb-filter-row select:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 2px rgba(0,123,255,.15);
}
/* ── Editable date cell ────────────────────────────────────── */
#fb-orders-grid tbody td.fb-editable-date {
cursor: pointer;
}
#fb-orders-grid tbody td.fb-editable-date:hover {
background-color: #fff8e1;
}
#fb-orders-grid tbody td.fb-editable-date input[type="date"] {
width: 120px;
font-size: 12px;
padding: 1px 4px;
border: 1px solid #80bdff;
border-radius: 3px;
}
/* ── Stripe rows ───────────────────────────────────────────── */
#fb-orders-grid tbody tr:nth-child(even) {
background-color: #f9f9f9;
}
#fb-orders-grid tbody tr:hover {
background-color: #eaf2ff;
}
/* ── Processing overlay ────────────────────────────────────── */
#fb-orders-grid_processing {
background: rgba(255,255,255,0.85);
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px 16px;
font-size: 13px;
}
/* autocomplete z-index fix in modals */
.ui-autocomplete { z-index: 1060 !important; }
</style>
<script>
$(function () {
/* ── Helpers ─────────────────────────────────────────────────── */
var _token = $('input[name="__RequestVerificationToken"]').val();
function antiForgery(obj) {
obj['__RequestVerificationToken'] = _token;
return obj;
}
var selectedIds = [];
function getSelectedIds() {
selectedIds = [];
$('#fb-orders-grid tbody .fb-row-check:checked').each(function () {
selectedIds.push($(this).val());
});
return selectedIds;
}
/* ── Column renderers ────────────────────────────────────────── */
function renderInnvoice(data) {
return data
? '<span class="badge badge-success">Igen</span>'
: '<span class="badge badge-secondary">Nem</span>';
}
function renderWeightValid(data) {
return data
? '<span class="badge badge-success">OK</span>'
: '<span class="badge badge-danger font-weight-bold">!</span>';
}
function renderMeasurable(data) {
return data
? '<span class="badge badge-info">Igen</span>'
: '<span class="badge badge-light text-secondary">Nem</span>';
}
function renderMeasuringStatus(val, row) {
var map = { 10: 'warning', 20: 'primary', 30: 'success', 40: 'danger' };
var cls = map[val] || 'secondary';
var label = row.MeasuringStatusString || String(val);
return '<span class="badge badge-' + cls + '">' + label + '</span>';
}
function renderDateOfReceipt(data) {
if (!data) return '<span class="text-muted">—</span>';
var d = new Date(data);
var dateStr = d.toLocaleDateString('hu-HU');
var timeStr = d.toLocaleTimeString('hu-HU', { hour: '2-digit', minute: '2-digit' });
return '<span>' + dateStr + ' ' + timeStr + '</span>';
}
function renderOrderStatus(statusId, row) {
var map = { 10: 'warning', 20: 'primary', 30: 'success', 40: 'danger' };
var cls = map[statusId] || 'secondary';
var label = row.OrderStatus || String(statusId);
return '<span class="badge badge-' + cls + '">' + label + '</span>';
}
/* ── DataTables ──────────────────────────────────────────────── */
var table = $('#fb-orders-grid').DataTable({
serverSide : true,
processing : true,
orderCellsTop: true,
stateSave : false,
pageLength : 50,
lengthMenu : [[20, 50, 100, 200, 500], [20, 50, 100, 200, 500]],
order : [[0, 'desc']],
language : {
processing : 'Betöltés...',
search : 'Keresés:',
lengthMenu : '_MENU_ sor/oldal',
info : '_START_ _END_ / _TOTAL_ rendelés',
infoEmpty : '0 rendelés',
infoFiltered : '(szűrve _MAX_-ból)',
paginate : { first: '««', previous: '«', next: '»', last: '»»' },
emptyTable : 'Nincs találat',
zeroRecords : 'Nincs találat a szűrési feltételekre'
},
ajax: {
url : '@Url.Action("FruitBankOrderList", "CustomOrder")',
type: 'POST',
data: function (d) {
d.StartDate = $('#@Html.IdFor(m => m.StartDate)').val();
d.EndDate = $('#@Html.IdFor(m => m.EndDate)').val();
d.BillingCompany = $('#BillingCompany').val();
addAntiForgeryToken(d);
},
error: function (xhr) {
console.error('FruitBankOrderList AJAX error:', xhr.status, xhr.responseText);
}
},
columns: [
/* 0 */ { data: 'Id', name: 'Id', orderable: false, searchable: false, width: '32px',
className: 'text-center',
render: function (d) { return '<input type="checkbox" class="fb-row-check" value="' + d + '">'; } },
/* 1 */ { data: 'CustomOrderNumber', name: 'CustomOrderNumber', width: '95px' },
/* 2 */ { data: 'CustomerCompany', name: 'CustomerCompany' },
/* 3 */ { data: 'InnvoiceTechId', name: 'InnvoiceTechId', orderable: false, width: '75px',
className: 'text-center',
render: function (d) { return renderInnvoice(d); } },
/* 4 */ { data: 'IsAllOrderItemAvgWeightValid', name: 'IsAllOrderItemAvgWeightValid', orderable: false, width: '55px',
className: 'text-center',
render: function (d) { return renderWeightValid(d); } },
/* 5 */ { data: 'IsMeasurable', name: 'IsMeasurable', orderable: false, width: '65px',
className: 'text-center',
render: function (d) { return renderMeasurable(d); } },
/* 6 */ { data: 'MeasuringStatus', name: 'MeasuringStatus', width: '95px',
className: 'text-center',
render: function (d, t, row) { return renderMeasuringStatus(d, row); } },
/* 7 */ { data: 'DateOfReceipt', name: 'DateOfReceipt', width: '110px',
className: 'text-center fb-editable-date',
render: function (d) { return renderDateOfReceipt(d); } },
/* 8 */ { data: 'OrderStatusId', name: 'OrderStatusId', width: '105px',
className: 'text-center',
render: function (d, t, row) { return renderOrderStatus(d, row); } },
/* 9 */ { data: 'PaymentStatus', name: 'PaymentStatus', orderable: false, width: '110px',
render: function (d) { return d || '—'; } },
/* 10 */ { data: 'ShippingStatus', name: 'ShippingStatus', orderable: false, width: '110px',
render: function (d) { return d || '—'; } },
/* 11 */ { data: 'CreatedOn', name: 'CreatedOn', width: '92px',
className: 'text-center',
render: function (d) { return d ? new Date(d).toLocaleDateString('hu-HU') : '—'; } },
/* 12 */ { data: 'OrderTotal', name: 'OrderTotal', orderable: false, width: '105px',
className: 'text-right' },
/* 13 */ { data: 'Id', name: null, orderable: false, searchable: false, width: '42px',
className: 'text-center',
render: function (d) { return '<a href="/Admin/Order/Edit/' + d + '" class="btn btn-default btn-xs" title="Szerkesztés"><i class="fas fa-pencil-alt"></i></a>'; } }
],
/* ── Per-column filter row ─────────────────────────────────── */
initComplete: function () {
var api = this.api();
var $thead = $(this).find('thead');
var $filterRow = $('<tr class="fb-filter-row"></tr>').appendTo($thead);
// Filter definition per column index:
// null = no filter, 'text' = text input, {type:'select', opts:[...]} = dropdown
var defs = [
null, /* 0 checkbox */
'text', /* 1 order # */
'text', /* 2 company */
{ type: 'select', opts: [['', 'Mind'], ['has', '✓ Igen'], ['none', '✗ Nem']] }, /* 3 innvoice */
null, /* 4 weight (no per-column filter) */
{ type: 'select', opts: [['', 'Mind'], ['true', 'Igen'], ['false', 'Nem']] }, /* 5 measurable */
{ type: 'select', opts: [['', 'Mind'], ['0', 'Nincs'], ['10', '…folyamat'], ['20', 'Mérésre'], ['30', 'Mérve'], ['40', 'Lezárva']] }, /* 6 measuring */
null, /* 7 date (top-level filter handles this) */
{ type: 'select', opts: [['', 'Mind'], ['10', 'Függőben'], ['20', 'Feldolgozás'], ['30', 'Teljesítve'], ['40', 'Törölve']] }, /* 8 order status */
null, /* 9 payment */
null, /* 10 shipping */
null, /* 11 created */
null, /* 12 total */
null /* 13 button */
];
api.columns().every(function (idx) {
var col = this;
var $th = $('<th></th>').appendTo($filterRow);
var def = defs[idx];
if (!def) return;
if (def === 'text') {
var $inp = $('<input type="text" placeholder="🔍">');
$inp.appendTo($th);
var timer;
$inp.on('input', function () {
clearTimeout(timer);
var v = this.value;
timer = setTimeout(function () { col.search(v).draw(); }, 450);
});
} else if (def.type === 'select') {
var $sel = $('<select></select>');
def.opts.forEach(function (o) {
$sel.append($('<option>').val(o[0]).text(o[1]));
});
$sel.appendTo($th);
$sel.on('change', function () { col.search(this.value).draw(); });
}
});
}
});
/* ── Search / filter triggers ────────────────────────────────── */
$('#fb-search-btn').on('click', function () { table.draw(); });
/* redraw on date change */
$('#@Html.IdFor(m => m.StartDate), #@Html.IdFor(m => m.EndDate)').on('change', function () { table.draw(); });
/* ── Partner (company) autocomplete ──────────────────────────── */
$('#fb-company-display').autocomplete({
delay : 400,
minLength: 2,
source : '@Url.Action("CustomerSearchAutoComplete", "CustomOrder")',
select : function (e, ui) {
$('#BillingCompany').val(ui.item.value);
$('#fb-company-display').val(ui.item.label);
$('#fb-company-clear').show();
table.draw();
return false;
}
});
$('#fb-company-clear').on('click', function () {
$('#BillingCompany').val('');
$('#fb-company-display').val('');
$(this).hide();
table.draw();
});
/* ── Checkbox: select all on current page ────────────────────── */
$('#fb-check-all').on('change', function () {
var checked = this.checked;
$('#fb-orders-grid tbody .fb-row-check').prop('checked', checked);
});
/* ── Inline editing: DateOfReceipt ───────────────────────────── */
$(document).on('click', '#fb-orders-grid tbody td.fb-editable-date', function (e) {
var $td = $(this);
if ($td.find('input').length) return; // already in edit mode
var $row = $td.closest('tr');
var rowData = table.row($row).data();
if (!rowData) return;
// Build a datetime-local string (YYYY-MM-DDTHH:mm) for the input value
function toDatetimeLocal(iso) {
if (!iso) return '';
var d = new Date(iso);
if (isNaN(d)) return '';
var pad = function(n) { return String(n).padStart(2, '0'); };
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
var currentIso = rowData.DateOfReceipt || null;
// Default to now if no date set yet
var inputVal = currentIso ? toDatetimeLocal(currentIso) : toDatetimeLocal(new Date().toISOString());
var orderId = rowData.Id;
var savedHtml = $td.html();
var $inp = $('<input type="datetime-local">').val(inputVal);
$td.html('').append($inp);
$inp.focus();
function restore() { $td.html(savedHtml); }
function persist() {
var newVal = $inp.val(); // format: YYYY-MM-DDTHH:mm
// Compare against original ISO; skip save only if identical
var newIso = newVal ? new Date(newVal).toISOString() : null;
var oldIso = currentIso ? new Date(currentIso).toISOString() : null;
if (newIso === oldIso) { restore(); return; }
$.ajax({
url : '@Url.Action("UpdateOrderField", "CustomOrder")',
type : 'POST',
data : antiForgery({ orderId: orderId, field: 'DateOfReceipt', value: newVal }),
success: function (res) {
if (res.success) {
rowData.DateOfReceipt = newVal || null;
table.row($row).data(rowData).invalidate();
/* re-render only the date cell without full redraw */
$td.html(renderDateOfReceipt(newVal));
} else {
restore();
alert('Mentési hiba: ' + (res.error || 'Ismeretlen hiba'));
}
},
error: function () { restore(); }
});
}
$inp.on('blur', function () { persist(); });
$inp.on('keydown', function (e) {
if (e.key === 'Enter') { $inp.off('blur'); persist(); }
if (e.key === 'Escape') { $inp.off('blur'); restore(); }
});
});
/* ── Go-to order by number ───────────────────────────────────── */
$('#@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)').on('keydown', function (e) {
if (e.keyCode === 13) { $('#go-to-order-by-number').trigger('click'); return false; }
});
$('#go-to-order-by-number').on('click', function () {
var num = $('#@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)').val();
if (num) {
window.location.href = '@Url.Action("GoToOrderId", "CustomOrder")?' +
'@Html.IdFor(m => m.GoDirectlyToCustomOrderNumber)=' + encodeURIComponent(num);
}
});
/* ── Export / PDF selected ───────────────────────────────────── */
function exportSelected(formId, inputId) {
var ids = getSelectedIds().join(',');
if (!ids) { alert('@T("Admin.Orders.NoOrders")'); return; }
$(inputId).val(ids);
$(formId).submit();
}
$('#exportxml-selected').on('click', function (e) { e.preventDefault(); exportSelected('#export-xml-selected-form', '#export-xml-ids'); });
$('#exportexcel-selected').on('click', function (e) { e.preventDefault(); exportSelected('#export-excel-selected-form', '#export-excel-ids'); });
$('#pdf-invoice-selected').on('click', function (e) { e.preventDefault(); exportSelected('#pdf-invoice-selected-form', '#pdf-invoice-ids'); });
/* ── Create order modal ──────────────────────────────────────── */
var createProducts = [];
$('#create-order-customer-search').autocomplete({
delay : 400,
minLength: 2,
source : '@Url.Action("CustomerSearchAutoComplete", "CustomOrder")',
select : function (e, ui) {
$('#create-order-customer-id').val(ui.item.value);
$('#create-order-customer-name').html('<strong>' + ui.item.label + '</strong>');
$('#create-order-customer-search').val('');
$('#create-product-search-section').slideDown();
return false;
}
});
$('#create-order-product-search').autocomplete({
delay : 400,
minLength: 2,
source : '@Url.Action("ProductSearchAutoComplete", "CustomOrder")',
select : function (e, ui) {
addCreateProduct(ui.item);
$('#create-order-product-search').val('');
return false;
}
});
function addCreateProduct(item) {
if (createProducts.find(function (p) { return p.id === item.value; })) return;
createProducts.push({ id: item.value, name: item.label, quantity: 1, price: item.price || 0 });
renderCreateProducts();
}
function renderCreateProducts() {
var $body = $('#create-products-body').empty();
if (!createProducts.length) { $('#create-selected-products-section').hide(); return; }
$('#create-selected-products-section').show();
createProducts.forEach(function (p, i) {
$body.append(
'<tr>' +
'<td><strong>' + p.name + '</strong></td>' +
'<td><input type="number" class="form-control form-control-sm" min="1" value="' + p.quantity + '" data-idx="' + i + '" onchange="window._fbUpdateQty(this)"></td>' +
'<td><input type="text" class="form-control form-control-sm" value="' + p.price + '" data-idx="' + i + '" onchange="window._fbUpdatePrice(this)"></td>' +
'<td class="text-center"><button type="button" class="btn btn-danger btn-xs" onclick="window._fbRemoveProduct(' + i + ')"><i class="fas fa-trash"></i></button></td>' +
'</tr>'
);
});
$('#create-order-products-json').val(JSON.stringify(createProducts));
}
window._fbUpdateQty = function (el) { createProducts[+el.dataset.idx].quantity = +el.value; renderCreateProducts(); };
window._fbUpdatePrice = function (el) { createProducts[+el.dataset.idx].price = +el.value; renderCreateProducts(); };
window._fbRemoveProduct = function (i) { createProducts.splice(i, 1); renderCreateProducts(); };
$('#create-order-form').on('submit', function (e) {
if (!$('#create-order-customer-id').val()) {
e.preventDefault();
$('#create-order-customer-error').show();
}
});
$('#create-order-window').on('hidden.bs.modal', function () {
$('#create-order-customer-search, #create-order-customer-id, #create-order-customer-name').val('').html('');
$('#create-order-customer-error').hide();
$('#create-product-search-section').hide();
createProducts = [];
renderCreateProducts();
});
});
</script>

View File

@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Nop.Plugin.Misc.FruitBankPlugin.Models;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Web.Areas.Admin.Models.Customers;
using Nop.Web.Framework.Components;
namespace Nop.Plugin.Misc.FruitBankPlugin.Components;
[ViewComponent(Name = "CustomerCreditWidget")]
public class CustomerCreditWidgetViewComponent : NopViewComponent
{
private readonly ICustomerCreditService _customerCreditService;
public CustomerCreditWidgetViewComponent(ICustomerCreditService customerCreditService)
{
_customerCreditService = customerCreditService;
}
public async Task<IViewComponentResult> InvokeAsync(string widgetZone, object additionalData)
{
if (additionalData is not CustomerModel customerModel) return Content("");
var customerId = customerModel.Id;
var credit = await _customerCreditService.GetByCustomerIdAsync(customerId);
var outstanding = await _customerCreditService.GetOutstandingBalanceAsync(customerId);
var model = new CustomerCreditWidgetModel
{
CustomerId = customerId,
HasCreditLimit = credit != null,
CreditLimit = credit?.CreditLimit ?? 0m,
OutstandingBalance = outstanding,
RemainingCredit = credit != null ? credit.CreditLimit - outstanding : (decimal?)null,
Comment = credit?.Comment
};
return View("~/Plugins/Misc.FruitBankPlugin/Views/CustomerCreditWidget.cshtml", model);
}
}

View File

@ -0,0 +1,441 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nop.Core;
using Nop.Core.Domain.Orders;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
using Nop.Services.Catalog;
using Nop.Services.Customers;
using Nop.Services.Localization;
using Nop.Services.Orders;
using Nop.Web.Framework.Controllers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
{
[AutoValidateAntiforgeryToken]
public class QuickOrderController : BasePluginController
{
private readonly IWorkContext _workContext;
private readonly IStoreContext _storeContext;
private readonly IProductService _productService;
private readonly IShoppingCartService _shoppingCartService;
private readonly ICustomerService _customerService;
private readonly ILocalizationService _localizationService;
private readonly CustomPriceCalculationService _customPriceCalculationService;
private readonly OpenAIApiService _aiApiService;
private readonly CerebrasAPIService _cerebrasApiService;
private readonly FruitBankDbContext _dbContext;
// Resource key prefix
private const string Prefix = "Plugins.Misc.FruitBankPlugin.QuickOrder.";
public QuickOrderController(
IWorkContext workContext,
IStoreContext storeContext,
IProductService productService,
IShoppingCartService shoppingCartService,
ICustomerService customerService,
ILocalizationService localizationService,
IPriceCalculationService priceCalculationService,
OpenAIApiService aiApiService,
CerebrasAPIService cerebrasApiService,
FruitBankDbContext dbContext)
{
_workContext = workContext;
_storeContext = storeContext;
_productService = productService;
_shoppingCartService = shoppingCartService;
_customerService = customerService;
_localizationService = localizationService;
_customPriceCalculationService = priceCalculationService as CustomPriceCalculationService;
_aiApiService = aiApiService;
_cerebrasApiService = cerebrasApiService;
_dbContext = dbContext;
}
[HttpGet]
public async Task<IActionResult> Index()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Challenge();
return View("~/Plugins/Misc.FruitBankPlugin/Views/QuickOrder/Index.cshtml");
}
/// <summary>
/// Return all available products with prices (for initial page load)
/// </summary>
[HttpGet]
public async Task<IActionResult> GetAllProducts()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
try
{
var store = await _storeContext.GetCurrentStoreAsync();
var allProductDtos = (await _dbContext.ProductDtos.GetAll(true).ToListAsync()).Where(pd => pd.AvailableQuantity > 0);
//var dbProducts = await _productService.SearchProductsAsync(
// pageIndex: 0,
// pageSize: 500,
// orderBy: );
var result = new List<object>();
foreach (var product in allProductDtos)
{
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
if (productDto == null) continue;
var availableQty = product.StockQuantity + productDto.IncomingQuantity;
if (availableQty <= 0) continue;
decimal? unitPrice = null;
if (!productDto.IsMeasurable)
{
var tproduct = await _productService.GetProductByIdAsync(productDto.Id);
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
tproduct, customer, store, null, 0, true, 1, null, null);
unitPrice = priceResult.finalPrice;
}
result.Add(new
{
id = product.Id,
name = product.Name,
quantity = 1,
requestedQuantity = 1,
unitPrice,
stockQuantity = availableQty,
searchTerm = (string)null,
isQuantityReduced = false,
isMeasurable = productDto.IsMeasurable
});
}
return Json(new { success = true, products = result });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] GetAllProducts error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Parse a manually typed product list and return matching products with prices
/// </summary>
[HttpPost]
public async Task<IActionResult> SearchProducts(string text)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (string.IsNullOrWhiteSpace(text))
return Json(new { success = false, message = await L("NoTextProvided") });
try
{
var parsedProducts = await ParseProductsFromText(text);
if (parsedProducts == null || parsedProducts.Count == 0)
return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = text });
var store = await _storeContext.GetCurrentStoreAsync();
var enrichedProducts = await EnrichProductData(parsedProducts, customer, store);
return Json(new { success = true, transcription = text, products = enrichedProducts });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] SearchProducts error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Transcribe voice audio (Hungarian) then parse and match products
/// </summary>
[HttpPost]
public async Task<IActionResult> TranscribeAndSearch(IFormFile audioFile)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (audioFile == null || audioFile.Length == 0)
return Json(new { success = false, message = await L("NoAudioReceived") });
try
{
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
if (string.IsNullOrEmpty(transcribedText))
return Json(new { success = false, message = await L("TranscriptionFailed") });
Console.WriteLine($"[QuickOrder] Transcription: {transcribedText}");
var parsedProducts = await ParseProductsFromText(transcribedText);
if (parsedProducts == null || parsedProducts.Count == 0)
return Json(new { success = false, message = await L("NoProductsIdentified"), transcription = transcribedText });
var store = await _storeContext.GetCurrentStoreAsync();
var enrichedProducts = await EnrichProductData(parsedProducts, customer, store);
return Json(new { success = true, transcription = transcribedText, products = enrichedProducts });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] TranscribeAndSearch error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Add a product to the current customer's shopping cart and return the updated cart
/// </summary>
[HttpPost]
public async Task<IActionResult> AddToCart(int productId, int quantity)
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false, message = await L("NotLoggedIn") });
if (productId <= 0 || quantity <= 0)
return Json(new { success = false, message = await L("InvalidProductOrQuantity") });
try
{
var product = await _productService.GetProductByIdAsync(productId);
if (product == null || product.Deleted || !product.Published)
return Json(new { success = false, message = await L("ProductNotAvailable") });
var store = await _storeContext.GetCurrentStoreAsync();
var warnings = await _shoppingCartService.AddToCartAsync(
customer: customer,
product: product,
shoppingCartType: ShoppingCartType.ShoppingCart,
storeId: store.Id,
quantity: quantity);
if (warnings.Any())
return Json(new { success = false, message = string.Join("; ", warnings) });
var cartItems = await GetCartItemsJson(customer, store);
return Json(new { success = true, cartItems });
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] AddToCart error: {ex.Message}");
return Json(new { success = false, message = $"Hiba: {ex.Message}" });
}
}
/// <summary>
/// Return the current customer's cart as JSON (for cart panel refresh)
/// </summary>
[HttpGet]
public async Task<IActionResult> GetCartItems()
{
var customer = await _workContext.GetCurrentCustomerAsync();
if (await _customerService.IsGuestAsync(customer))
return Json(new { success = false });
var store = await _storeContext.GetCurrentStoreAsync();
var cartItems = await GetCartItemsJson(customer, store);
return Json(new { success = true, cartItems });
}
#region Private helpers
/// <summary>Shorthand: get a localized QuickOrder resource string</summary>
private Task<string> L(string keySuffix)
=> _localizationService.GetResourceAsync(Prefix + keySuffix);
private async Task<string> TranscribeAudioFile(IFormFile audioFile, string language)
{
var fileName = $"quick_order_{DateTime.Now:yyyyMMdd_HHmmss}.webm";
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice");
if (!Directory.Exists(uploadsFolder))
Directory.CreateDirectory(uploadsFolder);
var filePath = Path.Combine(uploadsFolder, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
await audioFile.CopyToAsync(stream);
string transcribedText;
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, null);
if (!string.IsNullOrEmpty(transcribedText) &&
(transcribedText.EndsWith(".") || transcribedText.EndsWith("!") || transcribedText.EndsWith("?")))
transcribedText = transcribedText[..^1];
try { System.IO.File.Delete(filePath); } catch { /* ignore cleanup errors */ }
return transcribedText;
}
private async Task<List<ParsedProduct>> ParseProductsFromText(string text)
{
var systemPrompt = @"You are a product parser for a Hungarian fruit and vegetable wholesale company.
Parse the product names and quantities from the user's input.
CRITICAL RULES:
1. Extract product names and quantities from ANY produce item
2. Normalize product names to singular, lowercase (e.g., 'narancsok' 'narancs')
3. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, 'húsz' = 20, 'tíz' = 10, 'öt' = 5, 'egy' = 1)
4. Fix common transcription/typing errors (e.g., 'datója' 'datolya', 'szűlő' 'szőlő', 'mondarin' 'mandarin')
5. Return ONLY valid JSON array, no explanations
6. DO NOT include units - only product name and quantity as a number
7. ALWAYS return at least one product if you can parse anything from the input
OUTPUT FORMAT (JSON only):
[
{""product"": ""narancs"", ""quantity"": 100},
{""product"": ""alma"", ""quantity"": 50}
]";
var aiResponse = await _cerebrasApiService.GetSimpleResponseAsync(systemPrompt, $"Parse this: {text}");
Console.WriteLine($"[QuickOrder] AI parse response: {aiResponse}");
var jsonMatch = Regex.Match(aiResponse, @"\[.*\]", RegexOptions.Singleline);
if (!jsonMatch.Success) return new List<ParsedProduct>();
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<ParsedProduct>>(jsonMatch.Value)
?? new List<ParsedProduct>();
}
catch (Exception ex)
{
Console.WriteLine($"[QuickOrder] JSON parse error: {ex.Message}");
return new List<ParsedProduct>();
}
}
private async Task<List<object>> EnrichProductData(
List<ParsedProduct> parsedProducts,
Nop.Core.Domain.Customers.Customer customer,
Nop.Core.Domain.Stores.Store store)
{
var enrichedProducts = new List<object>();
var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
foreach (var parsed in parsedProducts)
{
var dbProducts = await _productService.SearchProductsAsync(
keywords: parsed.Product,
pageIndex: 0,
pageSize: 20);
if (!dbProducts.Any())
{
Console.WriteLine($"[QuickOrder] No products found for: {parsed.Product}");
continue;
}
foreach (var product in dbProducts)
{
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
if (productDto == null) continue;
var availableQty = product.StockQuantity + productDto.IncomingQuantity;
if (availableQty <= 0) continue;
var requestedQty = parsed.Quantity;
var finalQty = Math.Min(requestedQty, availableQty);
var isReduced = finalQty < requestedQty;
decimal? unitPrice = null;
if (!productDto.IsMeasurable)
{
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, finalQty, null, null);
unitPrice = priceResult.finalPrice;
}
enrichedProducts.Add(new
{
id = product.Id,
name = product.Name,
quantity = finalQty,
requestedQuantity = requestedQty,
unitPrice,
stockQuantity = availableQty,
searchTerm = parsed.Product,
isQuantityReduced = isReduced,
isMeasurable = productDto.IsMeasurable
});
}
}
Console.WriteLine($"[QuickOrder] Enriched product count: {enrichedProducts.Count}");
return enrichedProducts;
}
private async Task<List<object>> GetCartItemsJson(
Nop.Core.Domain.Customers.Customer customer,
Nop.Core.Domain.Stores.Store store)
{
var cart = await _shoppingCartService.GetShoppingCartAsync(
customer, ShoppingCartType.ShoppingCart, store.Id);
var allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
var result = new List<object>();
foreach (var item in cart)
{
var product = await _productService.GetProductByIdAsync(item.ProductId);
if (product == null) continue;
var productDto = allProductDtos.FirstOrDefault(x => x.Id == product.Id);
var isMeasurable = productDto?.IsMeasurable ?? false;
decimal? unitPrice = null;
if (!isMeasurable)
{
var priceResult = await _customPriceCalculationService.GetFinalPriceAsync(
product, customer, store, null, 0, true, item.Quantity, null, null);
unitPrice = priceResult.finalPrice;
}
result.Add(new
{
id = item.Id,
productId = item.ProductId,
name = product.Name,
quantity = item.Quantity,
unitPrice,
isMeasurable
});
}
return result;
}
#endregion
#region Inner models
private class ParsedProduct
{
[System.Text.Json.Serialization.JsonPropertyName("product")]
public string Product { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("quantity")]
public int Quantity { get; set; }
}
#endregion
}
}

View File

@ -0,0 +1,27 @@
using FruitBank.Common.Entities;
using LinqToDB;
using Mango.Nop.Core.Loggers;
using Mango.Nop.Data.Repositories;
using Nop.Core.Caching;
using Nop.Core.Configuration;
using Nop.Core.Events;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
public class CustomerCreditDbTable : MgDbTableBase<CustomerCredit>
{
public CustomerCreditDbTable(
IEventPublisher eventPublisher,
INopDataProvider dataProvider,
IShortTermCacheManager shortTermCacheManager,
IStaticCacheManager staticCacheManager,
AppSettings appSettings)
: base(eventPublisher, dataProvider, shortTermCacheManager, staticCacheManager, appSettings)
{
}
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
=> GetAll().FirstOrDefaultAsync(x => x.CustomerId == customerId);
}

View File

@ -40,7 +40,8 @@ public class FruitBankDbContext : MgDbContextBase,
IShippingItemPalletDbSet<ShippingItemPalletDbTable>,
IOrderItemPalletDbSet<OrderItemPalletDbTable>,
IShippingDocumentToFilesDbSet<ShippingDocumentToFilesDbTable>,
IFilesDbSet<FilesDbTable>
IFilesDbSet<FilesDbTable>,
ICustomerCreditDbSet<CustomerCreditDbTable>
{
private readonly FruitBankAttributeService _fruitBankAttributeService;
private readonly IStoreContext _storeContext;
@ -64,6 +65,7 @@ public class FruitBankDbContext : MgDbContextBase,
public FilesDbTable Files { get; set; }
public ShippingDocumentToFilesDbTable ShippingDocumentToFiles { get; set; }
public StockQuantityHistoryDtoDbTable StockQuantityHistoryDtos { get; set; }
public CustomerCreditDbTable CustomerCredits { get; set; }
public IRepository<Customer> Customers { get; set; }
public IRepository<CustomerRole> CustomerRoles { get; set; }
@ -79,7 +81,7 @@ public class FruitBankDbContext : MgDbContextBase,
PartnerDbTable partnerDbTable, ShippingDbTable shippingDbTable, ShippingDocumentDbTable shippingDocumentDbTable, ShippingItemDbTable shippingItemDbTable,
ShippingItemPalletDbTable shippingItemPalletDbTable, FilesDbTable filesDbTable, ShippingDocumentToFilesDbTable shippingDocumentToFilesDbTable,
ProductDtoDbTable productDtoDbTable, OrderDtoDbTable orderDtoDbTable, OrderItemDtoDbTable orderItemDtoDbTable, OrderItemPalletDbTable orderItemPalletDbTable,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos,
StockQuantityHistoryDtoDbTable stockQuantityHistoryDtos, CustomerCreditDbTable customerCreditDbTable,
IProductService productService, IStaticCacheManager staticCacheManager,
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository,
@ -127,6 +129,7 @@ public class FruitBankDbContext : MgDbContextBase,
StockQuantityHistories = stockQuantityHistories;
StockQuantityHistoriesExt = stockQuantityHistoriesExt;
StockQuantityHistoryDtos = stockQuantityHistoryDtos;
CustomerCredits = customerCreditDbTable;
}
public IQueryable<Customer> GetCustomersBySystemRoleName(string systemRoleName)

View File

@ -0,0 +1,10 @@
using FruitBank.Common.Entities;
using Mango.Nop.Data.Interfaces;
using Nop.Data;
namespace Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer.Interfaces;
public interface ICustomerCreditDbSet<TDbTable> : IMgDbTableBase where TDbTable : IRepository<CustomerCredit>
{
public TDbTable CustomerCredits { get; set; }
}

View File

@ -331,7 +331,9 @@ public class MgOrderModelFactory<TOrderListModelExt, TOrderModelExt> : OrderMode
public virtual async Task<TOrderListModelExt> PrepareOrderListModelExtendedAsync(OrderSearchModelExtended searchModel, Func<OrderListModel, TOrderModelExt, Task> dataItemCopiedCallback)
{
var customerCompany = searchModel.BillingCompany;
var customer = await _customerService.GetCustomerByIdAsync(Convert.ToInt32(customerCompany));
var customer = int.TryParse(customerCompany, out var customerId) && customerId > 0
? await _customerService.GetCustomerByIdAsync(customerId)
: null;
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
//var customer = customers.FirstOrDefault(c => c.Company != null && c.Company.Equals(customerCompany, StringComparison.InvariantCultureIgnoreCase));
OrderListModel prefiltered;

View File

@ -1,16 +1,18 @@
using FruitBank.Common.Server;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Nop.Plugin.Misc.FruitBankPlugin.Components;
using Nop.Core.Domain.Messages;
using Nop.Services.Cms;
using Nop.Services.Configuration;
using Nop.Services.Localization;
using Nop.Services.Messages;
using Nop.Services.Plugins;
using Nop.Services.Security;
using Nop.Web.Framework.Infrastructure;
using Nop.Web.Framework.Menu;
using Nop.Plugin.Misc.FruitBankPlugin.Services;
namespace Nop.Plugin.Misc.FruitBankPlugin
@ -29,6 +31,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
protected readonly ILocalizationService _localizationService;
protected readonly IUrlHelperFactory _urlHelperFactory;
private readonly IAdminMenu _adminMenu;
private readonly IMessageTemplateService _messageTemplateService;
//handle AdminMenuCreatedEvent
@ -39,7 +42,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
ILocalizationService localizationService,
IPermissionService permissionService,
IUrlHelperFactory urlHelperFactory,
IAdminMenu adminMenu)
IAdminMenu adminMenu,
IMessageTemplateService messageTemplateService)
{
_actionContextAccessor = actionContextAccessor;
_settingService = settingService;
@ -48,6 +52,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
_urlHelperFactory = urlHelperFactory;
_adminMenu = adminMenu;
_permissionService = permissionService;
_messageTemplateService = messageTemplateService;
}
// --- INSTALL ---
@ -59,7 +64,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
//TODO: Add "IsMeasurable" product attribute - FruitBankConst.IsMeasurableAttributeName
//TODO: Add "NeedsToBeMeasured" product attribute if not exists
//TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? ÁTGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kódban felülírja ha azonos key-el vannak! - J.
//TODO: Add unique index to GenericAttribute[EntityId, KeyGroup, Key]???!? ATGONDOLNI, mert lehet esetleg lista is a visszaadott, de a nopcommerce kodban felulirja ha azonos key-el vannak! - J.
// Default settings
var settings = new FruitBankSettings
@ -67,8 +72,217 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
ApiKey = string.Empty
};
await _settingService.SaveSettingAsync(settings);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Shipment", "EN");
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Szállítmányok", "HU");
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.Menu.ShipmentsList", "Sz\u00e1ll\u00edtm\u00e1nyok", "HU");
// ── Quick Order page ───────────────────────────────────────────────────
const string en = "EN";
const string hu = "HU";
// Page title
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Quick Order", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PageTitle", "Gyors rendel\u00e9s", hu);
// Navigation menu label
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Quick Order", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MenuLabel", "Gyors rendel\u00e9s", hu);
// Search bar
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Start voice recording", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicButtonTitle", "Hangfelv\u00e9tel ind\u00edt\u00e1sa", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Stop", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StopButtonTitle", "Le\u00e1ll\u00edt\u00e1s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Search for products (e.g. orange 100, apple 50) or use the microphone...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchPlaceholder", "Keress term\u00e9keket (pl. narancs 100, alma 50) vagy haszn\u00e1ld a mikrofont...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Listening... (start speaking)", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ActiveRecordingPlaceholder", "Figye\u0151s... (kezdj el besz\u00e9lni)", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Search", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchButton", "Keres\u00e9s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Listening...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ListeningStatus", "Figye\u0151s...", hu);
// Product panel
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "I heard:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscribedLabel", "Hallottam:", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "No products found. Try a different search.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoResultsText", "Nem tal\u00e1ltunk term\u00e9keket. Pr\u00f3b\u00e1ljunk m\u00e1s keres\u00e9st.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Loading products...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.LoadingProducts", "Term\u00e9kek bet\u00f6lt\u00e9se...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "All products", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AllProductsLabel", "\u00d6sszes term\u00e9k", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Results", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchResultsLabel", "Tal\u00e1latok", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 set quantity, then add to cart:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.QuantityHint", "\u2014 \u00e1ll\u00edtsd be a mennyis\u00e9get, majd add a kos\u00e1rhoz:", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "Requires weighing", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MeasurableBadge", "S\u00falym\u00e9r\u00e9st ig\u00e9nyel", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "Stock:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLabel", "K\u00e9szlet:", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Only", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedPrefix", "Csak", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "pcs available", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.StockLimitedSuffix", "db el\u00e9rhet\u0151", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "pcs", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PieceUnit", "db", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/pcs", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.PricePerPiece", "Ft/db", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Add to cart", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartTitle", "Kos\u00e1rba", hu);
// Cart panel
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Cart", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTitle", "Kos\u00e1r", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "Your cart is empty.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine1", "A kos\u00e1r \u00fcres.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Search for products and add them.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartEmptyLine2", "Keress term\u00e9keket \u00e9s add hozz\u00e1 \u0151ket.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "Prices for weighed items will be finalized after measurement.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CartTotalNote", "A s\u00falym\u00e9r\u00e9st ig\u00e9nyl\u0151 t\u00e9teleikn\u00e9l az \u00e1r a m\u00e9r\u00e9s ut\u00e1n v\u00e9glegesedik.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Estimated total:", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EstimatedTotal", "Becs\u00fclt \u00f6sszeg:", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Proceed to checkout", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.CheckoutButton", "Tov\u00e1bb a p\u00e9nzt\u00e1rhoz", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "View cart", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ViewCartButton", "Kos\u00e1r megtekint\u00e9se", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "added", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddedToCart", "hozz\u00e1adva", hu);
// JS voice / status strings
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "Your browser does not support audio recording.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.BrowserNotSupported", "A b\u00f6ng\u00e9sz\u0151 nem t\u00e1mogatja a hangfelv\u00e9telt.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Could not access microphone: ", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicAccessError", "Nem siker\u00fclt a mikrofon el\u00e9r\u00e9se: ", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Please allow microphone access.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicPermissionDenied", "Enged\u00e9lyezd a mikrofon haszn\u00e1lat\u00e1t.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "No microphone found.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.MicNotFound", "Nincs mikrofon csatlakoztatva.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Calibrating...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Calibrating", "Kalib\u00e1l\u00f3d\u00e1s...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Processing...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Processing", "Feldolgoz\u00e1s...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Could not record audio. Please try again.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.RecordingFailed", "Nem siker\u00fclt hangot r\u00f6gz\u00edteni. K\u00e9rem, pr\u00f3b\u00e1lja \u00fajra.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Loud and clear", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeHigh", "Hangos \u00e9s \u00e9rhet\u0151", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Speaking...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeSpeaking", "Besz\u00e9l...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Speak louder!", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.VolumeLouder", "Hangosabban!", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Searching...", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.Searching", "Keres\u00e9s...", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "Please enter the products!", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.EnterProducts", "K\u00e9rem, add meg a term\u00e9keket!", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Error during search.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.SearchError", "Hiba a keres\u00e9s sor\u00e1n.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Error processing audio.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AudioError", "Hiba a hangfeldolgoz\u00e1s sor\u00e1n.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Error adding item to cart.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.AddToCartError", "Hiba a kos\u00e1rba helyez\u00e9s sor\u00e1n.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Error: ", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ErrorPrefix", "Hiba: ", hu);
// Controller JSON error messages
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Not logged in", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NotLoggedIn", "Nincs bejelentkezve", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "No text provided", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoTextProvided", "Nincs sz\u00f6veg megadva", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Could not identify products", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoProductsIdentified", "Nem siker\u00fclt term\u00e9keket azonos\u00edtani", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "No audio received", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.NoAudioReceived", "Nem \u00e9rkezett hangf\u00e1jl", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Speech recognition failed", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.TranscriptionFailed", "Nem siker\u00fclt a hangfelismer\u00e9s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "Product not available", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.ProductNotAvailable", "A term\u00e9k nem el\u00e9rhet\u0151", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "Invalid product or quantity", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.QuickOrder.InvalidProductOrQuantity", "\u00c9rv\u00e9nytelen term\u00e9k vagy mennyis\u00e9g", hu);
// ── Customer Credit ────────────────────────────────────────────────────
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "Customer Credit Management", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle", "\u00dcgyf\u00e9l hitelkeret kezel\u00e9s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Back to customer", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer", "Vissza az \u00fcgyf\u00e9lhez", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Set Credit Limit", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle", "Hitelkeret be\u00e1ll\u00edt\u00e1sa", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Credit Limit", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit", "Hitelkeret", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "Set to 0 to block all orders. Leave the record absent to allow unlimited.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint", "0 eset\u00e9n minden rendel\u00e9s le van tiltva. Ha nincs rekord, a limit korl\u00e1tlan.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Outstanding Balance", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance", "Kintlév\u0151 egyenleg", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Remaining Credit", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit", "Szabad keret", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Unlimited", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited", "Korl\u00e1tlan", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Notes", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment", "Megjegyz\u00e9s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Save", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Save", "Ment\u00e9s", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Unpaid / Pending Orders", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle", "Kifizetetlen / f\u00fcgg\u0151 rendel\u00e9sek", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "No unpaid orders.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders", "Nincs kifizetetlen rendel\u00e9s.", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Order #", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId", "Rendel\u00e9s #", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "Date", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate", "D\u00e1tum", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "Total", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal", "\u00d6sszeg", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Order Status", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus", "Rendel\u00e9s \u00e1llapot", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Payment Status", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus", "Fizet\u00e9si \u00e1llapot", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "Total", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.Total", "\u00d6sszesen", hu);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.", en);
await _localizationService.AddOrUpdateLocaleResourceAsync("Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked", "A rendel\u00e9st nem lehet leadni, mert a kintlév\u0151 egyenlege el\u00e9rte a hitelkeret\u00e9t. K\u00e9rj\u00fck, el\u0151sz\u00f6r rendezze meglév\u0151 tartoz\u00e1s\u00e1t.", hu);
// ── Order Started email template ────────────────────────────────────
var existingStartedTemplate = await _messageTemplateService
.GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME, 0);
if (!existingStartedTemplate.Any())
{
await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate
{
Name = FruitBankNotificationService.ORDER_STARTED_TEMPLATE_NAME,
Subject = "%Store.Name% - Rendelésed feldolgozás alatt (#%Order.OrderNumber%)",
Body = "<p>Kedves %Order.CustomerFullName%,</p>" +
"<p>Rendelésedet (<strong>#%Order.OrderNumber%</strong>) elkezdtük feldolgozni.</p>" +
"%Order.MeasurableNote%" +
"<p>Amint elkészül, értesítünk!</p>" +
"<p>%Store.Name% csapata</p>",
IsActive = true,
EmailAccountId = 0,
LimitedToStores = false,
AllowDirectReply = false,
AttachedDownloadId = 0,
});
}
// ── Order Audited email template ─────────────────────────────────────
var existingTemplate = await _messageTemplateService
.GetMessageTemplatesByNameAsync(FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME, 0);
if (existingTemplate.Count == 0)
{
await _messageTemplateService.InsertMessageTemplateAsync(new MessageTemplate
{
Name = FruitBankNotificationService.ORDER_AUDITED_TEMPLATE_NAME,
Subject = "%Store.Name% - Rendelésed elkészült (#%Order.OrderNumber%)",
Body = "<p>Kedves %Order.CustomerFullName%,</p>" +
"<p>Rendelésed (<strong>#%Order.OrderNumber%</strong>) elkészült és átvételre vár.</p>" +
"%Order.MeasurableNote%" +
"<p>Végleges összeg: <strong>%Order.OrderTotal%</strong></p>" +
"<p>Köszönjük a rendelésedet!</p>" +
"<p>%Store.Name% csapata</p>",
IsActive = true,
EmailAccountId = 0, // 0 = use store default
LimitedToStores = false,
AllowDirectReply = false,
AttachedDownloadId = 0,
});
}
await base.InstallAsync();
}
@ -84,36 +298,20 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
public Task<IList<string>> GetWidgetZonesAsync()
{
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,
AdminWidgetZones.CustomerDetailsBlock
});
}
//public string GetWidgetViewComponentName(string widgetZone)
//{
// return "ProductAIWidget"; // A ViewComponent neve
//}
// --- ADMIN MENÜ ---
//public async Task ManageSiteMapAsync(AdminMenuItem rootNode)
//{
// if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS))
// return;
// var pluginNode = new AdminMenuItem
// {
// SystemName = "FruitBankPlugin.Configure",
// Title = "AI Assistant",
// Url = $"{_webHelper.GetStoreLocation()}Admin/FruitBankPluginAdmin/Configure",
// Visible = true
// };
// rootNode.ChildNodes.Add(pluginNode);
// //return Task.CompletedTask;
//}
public async Task ManageSiteMapAsync(AdminMenuItem rootNode)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Configuration.MANAGE_PLUGINS))
return;
}
public override string GetConfigurationPageUrl()
@ -132,21 +330,23 @@ namespace Nop.Plugin.Misc.FruitBankPlugin
{
if (widgetZone == PublicWidgetZones.ProductBoxAddinfoBefore || widgetZone == PublicWidgetZones.ProductDetailsBottom)
{
return zones.Any(widgetZone.Equals) ? typeof(ProductAIWidgetViewComponent) : null;
return typeof(ProductAIWidgetViewComponent);
}
else if (widgetZone == AdminWidgetZones.ProductDetailsBlock)
{
return zones.Any(widgetZone.Equals) ? typeof(ProductAttributesViewComponent) : null;
return typeof(ProductAttributesViewComponent);
}
else if (widgetZone == AdminWidgetZones.OrderDetailsBlock)
{
return zones.Any(widgetZone.Equals) ? typeof(OrderAttributesViewComponent) : null;
return typeof(OrderAttributesViewComponent);
}
else if (widgetZone == AdminWidgetZones.CustomerDetailsBlock)
{
return typeof(CustomerCreditWidgetViewComponent);
}
}
return null;
}
}
}

View File

@ -0,0 +1,237 @@
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Nop.Core;
using Nop.Core.Domain;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Directory;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Core.Domain.Tax;
using Nop.Core.Domain.Vendors;
using Nop.Core.Events;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
using Nop.Services.Attributes;
using Nop.Services.Blogs;
using Nop.Services.Catalog;
using Nop.Services.Common;
using Nop.Services.Customers;
using Nop.Services.Directory;
using Nop.Services.Events;
using Nop.Services.Helpers;
using Nop.Services.Html;
using Nop.Services.Localization;
using Nop.Services.Logging;
using Nop.Services.Messages;
using Nop.Services.News;
using Nop.Services.Orders;
using Nop.Services.Payments;
using Nop.Services.Seo;
using Nop.Services.Shipping;
using Nop.Services.Stores;
using Nop.Services.Vendors;
using System.ComponentModel.DataAnnotations;
using System.Text;
namespace Nop.Plugin.Misc.FruitBankPlugin.Infrastructure
{
public class FruitBankMessageTokenProvider : MessageTokenProvider
{
private readonly IOrderService _orderService;
private readonly IPriceFormatter _priceFormatter;
private readonly ICurrencyService _currencyService;
private readonly CurrencySettings _currencySettings;
private readonly FruitBankDbContext _dbContext;
public FruitBankMessageTokenProvider(
CatalogSettings catalogSettings,
CurrencySettings currencySettings,
IActionContextAccessor actionContextAccessor,
IAddressService addressService,
IAttributeFormatter<AddressAttribute, AddressAttributeValue> addressAttributeFormatter,
IAttributeFormatter<CustomerAttribute, CustomerAttributeValue> customerAttributeFormatter,
IAttributeFormatter<VendorAttribute, VendorAttributeValue> vendorAttributeFormatter,
IBlogService blogService,
ICountryService countryService,
ICurrencyService currencyService,
ICustomerService customerService,
IDateTimeHelper dateTimeHelper,
IEventPublisher eventPublisher,
IGenericAttributeService genericAttributeService,
IGiftCardService giftCardService,
IHtmlFormatter htmlFormatter,
ILanguageService languageService,
ILocalizationService localizationService,
ILogger logger,
INewsService newsService,
IOrderService orderService,
IPaymentPluginManager paymentPluginManager,
IPaymentService paymentService,
IPriceFormatter priceFormatter,
IProductService productService,
IRewardPointService rewardPointService,
IShipmentService shipmentService,
IStateProvinceService stateProvinceService,
IStoreContext storeContext,
IStoreService storeService,
IUrlHelperFactory urlHelperFactory,
IUrlRecordService urlRecordService,
IWorkContext workContext,
MessageTemplatesSettings templatesSettings,
PaymentSettings paymentSettings,
StoreInformationSettings storeInformationSettings,
TaxSettings taxSettings,
FruitBankDbContext dbContext
) : base(
catalogSettings,
currencySettings,
actionContextAccessor,
addressService,
addressAttributeFormatter,
customerAttributeFormatter,
vendorAttributeFormatter,
blogService,
countryService,
currencyService,
customerService,
dateTimeHelper,
eventPublisher,
genericAttributeService,
giftCardService,
htmlFormatter,
languageService,
localizationService,
logger,
newsService,
orderService,
paymentPluginManager,
paymentService,
priceFormatter,
productService,
rewardPointService,
shipmentService,
stateProvinceService,
storeContext,
storeService,
urlHelperFactory,
urlRecordService,
workContext,
templatesSettings,
paymentSettings,
storeInformationSettings,
taxSettings)
{
_orderService = orderService;
_priceFormatter = priceFormatter;
_currencyService = currencyService;
_currencySettings = currencySettings;
_dbContext = dbContext;
}
public override async Task AddOrderTokensAsync(
IList<Token> tokens,
Order order,
int languageId,
int vendorId = 0)
{
// Run base first to populate all other Order.* tokens
await base.AddOrderTokensAsync(tokens, order, languageId, vendorId);
// Replace the product table token with our custom version
var existing = tokens.FirstOrDefault(t => t.Key == "Order.Product(s)");
if (existing != null)
tokens.Remove(existing);
tokens.Add(new Token("Order.Product(s)", await BuildCustomProductTableAsync(order, languageId), true));
}
private async Task<string> BuildCustomProductTableAsync(Order order, int languageId)
{
var currency = await _currencyService.GetCurrencyByCodeAsync(order.CustomerCurrencyCode)
?? await _currencyService.GetCurrencyByIdAsync(_currencySettings.PrimaryStoreCurrencyId);
var items = await _orderService.GetOrderItemsAsync(order.Id);
var itemDtos = await _dbContext.OrderItemDtos.GetAllByOrderId(order.Id).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine(@"
<table cellspacing=""0"" cellpadding=""6"" border=""1"" style=""width:100%;border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px;"">
<thead>
<tr style=""background-color:#4a7c3f;color:#ffffff;"">
<th style=""text-align:left;padding:8px;"">Termék</th>
<th style=""text-align:center;padding:8px;"">Mennyiség</th>
<th style=""text-align:right;padding:8px;"">Egységár</th>
<th style=""text-align:right;padding:8px;"">Összesen</th>
</tr>
</thead>
<tbody>");
var rowIndex = 0;
foreach (var item in itemDtos)
{
var product = await _orderService.GetProductByOrderItemIdAsync(item.Id);
if (product == null) continue;
var unitPrice = await _priceFormatter.FormatPriceAsync(
item.UnitPriceInclTax, true, currency, languageId, true);
var lineTotal = await _priceFormatter.FormatPriceAsync(
item.PriceInclTax, true, currency, languageId, true);
var rowBg = rowIndex % 2 == 0 ? "#ffffff" : "#f2f7f0";
rowIndex++;
if (item.IsMeasurable)
{
var averageWeight = item.AverageWeight;
var approximatePrice = item.Quantity * item.UnitPriceInclTax * (decimal)averageWeight;
sb.AppendLine($@"
<tr style=""background-color:{rowBg};"">
<td style=""padding:8px;"">{product.Name}</td>
<td style=""padding:8px;text-align:center;"">{item.Quantity}</td>
<td style=""padding:8px;text-align:right;"">{unitPrice}</td>
<td style=""padding:8px;text-align:right;"">Kalkuláció alatt, nagyságrendileg {approximatePrice}</td>
</tr>");
}
else
{
sb.AppendLine($@"
<tr style=""background-color:{rowBg};"">
<td style=""padding:8px;"">{product.Name}</td>
<td style=""padding:8px;text-align:center;"">{item.Quantity}</td>
<td style=""padding:8px;text-align:right;"">{unitPrice}</td>
<td style=""padding:8px;text-align:right;"">{lineTotal}</td>
</tr>");
}
}
var orderTotal = await _priceFormatter.FormatPriceAsync(
order.OrderTotal, true, currency, languageId, true);
if(itemDtos.Any(i => i.IsMeasurable))
{
sb.AppendLine($@"
<tr style=""background-color:#e8f0e5;font-weight:bold;"">
<td colspan=""3"" style=""padding:8px;text-align:right;"">Végösszeg:</td>
<td style=""padding:8px;text-align:right;"">Mérendő termék miatt kalkuláció alatt...</td>
</tr>");
}
else
{
sb.AppendLine($@"
<tr style=""background-color:#e8f0e5;font-weight:bold;"">
<td colspan=""3"" style=""padding:8px;text-align:right;"">Végösszeg:</td>
<td style=""padding:8px;text-align:right;"">{orderTotal}</td>
</tr>");
}
sb.AppendLine(" </tbody>\n</table>");
return sb.ToString();
}
}
}

View File

@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Nop.Core.Domain.Orders;
using Nop.Core.Infrastructure;
using Nop.Data;
@ -80,6 +81,7 @@ public class PluginNopStartup : INopStartup
services.AddScoped<StockTakingDbTable>();
services.AddScoped<StockTakingItemDbTable>();
services.AddScoped<StockTakingItemPalletDbTable>();
services.AddScoped<CustomerCreditDbTable>();
services.AddScoped<StockTakingDbContext>();
services.AddScoped<FruitBankDbContext>();
@ -90,8 +92,15 @@ public class PluginNopStartup : INopStartup
services.AddScoped<IStockSignalREndpointServer, StockSignalREndpointServer>();
//services.AddScoped<CustomModelFactory, ICustomerModelFactory>();
services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
services.AddScoped<PriceCalculationService, CustomPriceCalculationService>();
//services.AddScoped<IPriceCalculationService, CustomPriceCalculationService>();
//services.AddScoped<PriceCalculationService, CustomPriceCalculationService>();
services.Replace(
ServiceDescriptor.Scoped<IPriceCalculationService, CustomPriceCalculationService>()
);
//services.AddScoped<IMessageTokenProvider, FruitBankMessageTokenProvider>();
services.Replace(
ServiceDescriptor.Scoped<IMessageTokenProvider, FruitBankMessageTokenProvider>()
);
services.AddScoped<IConsumer<OrderPlacedEvent>, EventConsumer>();
services.AddScoped<IOrderMeasurementService, OrderMeasurementService>();
services.AddScoped<PendingMeasurementCheckoutFilter>();
@ -119,8 +128,7 @@ public class PluginNopStartup : INopStartup
//services.AddScoped<IAIAPIService, OpenAIApiService>();
services.AddScoped<AICalculationService>();
services.AddScoped<PdfToImageService>();
services.AddScoped<IWorkflowMessageService, WorkflowMessageService>();
services.AddScoped<FruitBankNotificationService>();
services.AddSingleton<IFileStorageProvider>(sp =>
new LocalFileStorageProvider() // Uses default wwwroot/uploads
// Or specify custom path:
@ -129,6 +137,7 @@ public class PluginNopStartup : INopStartup
// Register the file storage service
services.AddScoped<FileStorageService>();
services.AddScoped<ICustomerCreditService, CustomerCreditService>();
services.AddControllersWithViews(options =>
{

View File

@ -21,61 +21,60 @@ public class RouteProvider : IRouteProvider
pattern: "Admin/FruitBankPlugin/Configure",
defaults: new { controller = "FruitBankPluginAdmin", action = "Configure", area = AreaNames.ADMIN });
//endpointRouteBuilder.MapHub<FruitBankHub>("/fbhub");//.RequireCors("AllowBlazorClient");
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Order.List",
pattern: "Admin/Order/List",
defaults: new { controller = "CustomOrder", action = "List", area = AreaNames.ADMIN }
//constraints: new { area = AreaNames.ADMIN }
);
defaults: new { controller = "CustomOrder", action = "NewList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Order.OrderList",
pattern: "Admin/Order/OrderList",
defaults: new { controller = "CustomOrder", action = "OrderList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Order.FruitBankOrderList",
pattern: "Admin/CustomOrder/FruitBankOrderList",
defaults: new { controller = "CustomOrder", action = "FruitBankOrderList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Order.UpdateOrderField",
pattern: "Admin/CustomOrder/UpdateOrderField",
defaults: new { controller = "CustomOrder", action = "UpdateOrderField", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Order.Test",
pattern: "Admin/Order/Test",
defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN }
//constraints: new { area = AreaNames.ADMIN }
);
defaults: new { controller = "CustomOrder", action = "Test", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Index",
pattern: "Admin",
defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN }
//constraints: new { area = AreaNames.ADMIN }
);
defaults: new { controller = "CustomDashboard", action = "Index", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Shipping.List",
pattern: "Admin/Shipping/List",
defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN }
);
defaults: new { controller = "Shipping", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Shipping.ShippingList",
pattern: "Admin/Shipping/ShippingList",
defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN }
);
defaults: new { controller = "Shipping", action = "ShippingList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Invoices.List",
pattern: "Admin/Invoices/List",
defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN }
);
defaults: new { controller = "Invoice", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Shipping.Create",
pattern: "Admin/Shipping/Create",
defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN });
name: "Plugin.FruitBank.Admin.Shipping.Create",
pattern: "Admin/Shipping/Create",
defaults: new { controller = "Shipping", action = "Create", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Shipping.Edit",
pattern: "Admin/Shipping/Edit",
defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN });
name: "Plugin.FruitBank.Admin.Shipping.Edit",
pattern: "Admin/Shipping/Edit",
defaults: new { controller = "Shipping", action = "Edit", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Shipping.UploadFile",
@ -121,7 +120,7 @@ public class RouteProvider : IRouteProvider
name: "Plugin.FruitBank.Admin.Products.List",
pattern: "Admin/Product/List",
defaults: new { controller = "CustomProduct", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Products.ProductList",
pattern: "Admin/Product/ProductList",
@ -153,9 +152,9 @@ public class RouteProvider : IRouteProvider
defaults: new { controller = "CustomOrder", action = "Edit", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.Order.AddProduct",
pattern: "Admin/CustomOrder/FruitBankAddProductToOrder",
defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN });
name: "Plugin.FruitBank.Admin.Order.AddProduct",
pattern: "Admin/CustomOrder/FruitBankAddProductToOrder",
defaults: new { controller = "CustomOrder", action = "FruitBankAddProductToOrder", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.ManagementPage.ProcessShippingDocument",
@ -181,10 +180,57 @@ public class RouteProvider : IRouteProvider
name: "Plugin.FruitBank.Admin.ExtractText",
pattern: "Admin/ExtractText",
defaults: new { controller = "FileManager", action = "ImageTextExtraction", area = AreaNames.ADMIN });
// ── Customer Credit ──────────────────────────────────────────────────
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.CustomerCredit.List",
pattern: "Admin/CustomerCredit/List",
defaults: new { controller = "CustomerCredit", action = "List", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.CustomerCredit.CustomerCreditList",
pattern: "Admin/CustomerCredit/CustomerCreditList",
defaults: new { controller = "CustomerCredit", action = "CustomerCreditList", area = AreaNames.ADMIN });
endpointRouteBuilder.MapControllerRoute(
name: "Plugin.FruitBank.Admin.CustomerCredit.UpdateCreditLimit",
pattern: "Admin/CustomerCredit/UpdateCreditLimit",
defaults: new { controller = "CustomerCredit", action = "UpdateCreditLimit", 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>
/// Gets a priority of route provider
/// </summary>
public int Priority => 4000;
}
}

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="English" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
Import: Admin > Configuration > Languages > [English] > Import resources
═══════════════════════════════════════════════════════════ -->
<!-- Page -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
<Value><![CDATA[Customer Credit Management]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
<Value><![CDATA[Back to customer]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
<Value><![CDATA[Set Credit Limit]]></Value>
</LocaleResource>
<!-- Summary cards -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
<Value><![CDATA[Credit Limit]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
<Value><![CDATA[Set to 0 to block all orders. Leave the record absent to allow unlimited.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
<Value><![CDATA[Outstanding Balance]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
<Value><![CDATA[Remaining Credit]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
<Value><![CDATA[Unlimited]]></Value>
</LocaleResource>
<!-- Form -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
<Value><![CDATA[Notes]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
<Value><![CDATA[Save]]></Value>
</LocaleResource>
<!-- List page -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
<Value><![CDATA[Customer Name]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
<Value><![CDATA[Email]]></Value>
</LocaleResource>
<!-- Unpaid orders table -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
<Value><![CDATA[Unpaid / Pending Orders]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
<Value><![CDATA[No unpaid orders.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
<Value><![CDATA[Order #]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
<Value><![CDATA[Date]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
<Value><![CDATA[Total]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
<Value><![CDATA[Order Status]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
<Value><![CDATA[Payment Status]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
<Value><![CDATA[Total]]></Value>
</LocaleResource>
<!-- Enforcement -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
<Value><![CDATA[Your order cannot be placed because your outstanding balance has reached your credit limit. Please settle your existing balance first.]]></Value>
</LocaleResource>
</Language>

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<Language Name="Hungarian" IsDefault="false" IsRightToLeft="false">
<!-- ═══════════════════════════════════════════════════════════
Customer Credit — Plugins.Misc.FruitBankPlugin.CustomerCredit.*
Import: Admin > Konfiguráció > Nyelvek > [Magyar] > Erőforrások importálása
═══════════════════════════════════════════════════════════ -->
<!-- Oldal -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle">
<Value><![CDATA[Ügyfél hitelkeret kezelés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.BackToCustomer">
<Value><![CDATA[Vissza az ügyfélhez]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle">
<Value><![CDATA[Hitelkeret beállítása]]></Value>
</LocaleResource>
<!-- Összefoglaló kártyák -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit">
<Value><![CDATA[Hitelkeret]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimitHint">
<Value><![CDATA[0 esetén minden rendelés le van tiltva. Ha nincs rekord, a limit korlátlan.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance">
<Value><![CDATA[Kintlévő egyenleg]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit">
<Value><![CDATA[Szabad keret]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited">
<Value><![CDATA[Korlátlan]]></Value>
</LocaleResource>
<!-- Űrlap -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment">
<Value><![CDATA[Megjegyzés]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Save">
<Value><![CDATA[Mentés]]></Value>
</LocaleResource>
<!-- Lista oldal -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerName">
<Value><![CDATA[Ügyfél neve]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.CustomerEmail">
<Value><![CDATA[Email]]></Value>
</LocaleResource>
<!-- Kifizetetlen rendelések táblázat -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.UnpaidOrdersTitle">
<Value><![CDATA[Kifizetetlen / függő rendelések]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.NoUnpaidOrders">
<Value><![CDATA[Nincs kifizetetlen rendelés.]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderId">
<Value><![CDATA[Rendelés #]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderDate">
<Value><![CDATA[Dátum]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderTotal">
<Value><![CDATA[Összeg]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderStatus">
<Value><![CDATA[Rendelés állapot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.PaymentStatus">
<Value><![CDATA[Fizetési állapot]]></Value>
</LocaleResource>
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.Total">
<Value><![CDATA[Összesen]]></Value>
</LocaleResource>
<!-- Hitelkeret túllépés hibaüzenet -->
<LocaleResource Name="Plugins.Misc.FruitBankPlugin.CustomerCredit.OrderBlocked">
<Value><![CDATA[A rendelést nem lehet leadni, mert a kintlévő egyenlege elérte a hitelkeretét. Kérjük, először rendezze meglévő tartozását.]]></Value>
</LocaleResource>
</Language>

View File

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

View File

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

View File

@ -42,6 +42,7 @@ public partial class NameCompatibility : INameCompatibility
{ typeof(StockTaking), FruitBankConstClient.StockTakingDbTableName},
{ typeof(StockTakingItem), FruitBankConstClient.StockTakingItemDbTableName},
{ typeof(StockTakingItemPallet), FruitBankConstClient.StockTakingItemPalletDbTableName},
{ typeof(CustomerCredit), FruitBankConstClient.CustomerCreditDbTableName},
};

View File

@ -0,0 +1,21 @@
using Nop.Web.Framework.Models;
using Nop.Web.Framework.Mvc.ModelBinding;
namespace Nop.Plugin.Misc.FruitBankPlugin.Models;
public record CustomerCreditWidgetModel : BaseNopModel
{
public int CustomerId { get; set; }
public bool HasCreditLimit { get; set; }
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")]
public decimal CreditLimit { get; set; }
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")]
public decimal OutstandingBalance { get; set; }
[NopResourceDisplayName("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")]
public decimal? RemainingCredit { get; set; }
public string? Comment { get; set; }
}

View File

@ -0,0 +1,35 @@
using System;
namespace Nop.Plugin.Misc.FruitBankPlugin.Models.Orders
{
/// <summary>
/// Lightweight DTO returned by the FruitBankOrderList endpoint.
/// Contains only what the grid needs avoids serialising heavy OrderModel navigation properties.
/// </summary>
public record FruitBankOrderRowDto
{
public int Id { get; init; }
public string CustomOrderNumber { get; init; }
public string CustomerCompany { get; init; }
public int CustomerId { get; init; }
// FruitBank-specific fields
public string InnvoiceTechId { get; init; }
public bool IsAllOrderItemAvgWeightValid { get; init; }
public bool IsMeasurable { get; init; }
public int MeasuringStatus { get; init; }
public string MeasuringStatusString { get; init; }
public DateTime? DateOfReceipt { get; init; }
// NopCommerce order fields
public int OrderStatusId { get; init; }
public string OrderStatus { get; init; }
public int PaymentStatusId { get; init; }
public string PaymentStatus { get; init; }
public int ShippingStatusId { get; init; }
public string ShippingStatus { get; init; }
public string StoreName { get; init; }
public DateTime CreatedOn { get; init; }
public string OrderTotal { get; init; }
}
}

View File

@ -10,6 +10,7 @@
<ItemGroup>
<None Remove="Areas\Admin\Views\Order\Edit.cshtml" />
<None Remove="css\quick-order.css" />
<None Remove="logo.jpg" />
<None Remove="plugin.json" />
<None Remove="Views\_ViewImports.cshtml" />
@ -65,6 +66,9 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="css\quick-order.css">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="logo.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@ -169,6 +173,12 @@
<None Update="Areas\Admin\Views\AppDownload\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\CustomerCredit\Details.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\CustomerCredit\List.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Extras\ImageTextExtraction.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -181,6 +191,9 @@
<None Update="Areas\Admin\Views\Order\FileUploadGridComponent.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Order\FruitBankOrderList.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Areas\Admin\Views\Order\TestGridComponent.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -646,6 +659,9 @@
<None Update="Views\Checkout\PendingMeasurementWarning.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\CustomerCreditWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\ProductAIListWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@ -661,6 +677,9 @@
<None Update="Views\ProductAIWidget.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Views\QuickOrder\Index.cshtml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>

View File

@ -0,0 +1,351 @@
# FruitBank Plugin Claude Skill Reference
> **Purpose:** This file is a reference document for Claude to quickly understand the FruitBank NopCommerce plugin codebase, patterns, and conventions so that new work sessions can ramp up without re-reading the entire codebase from scratch.
---
## 1. Project Identity
| Property | Value |
|---|---|
| Plugin system name | `Misc.FruitBankPlugin` |
| DLL | `Nop.Plugin.Misc.FruitBankPlugin.dll` |
| Namespace root | `Nop.Plugin.Misc.FruitBankPlugin` |
| NopCommerce version | **4.80** |
| Author | Adam Gelencser |
| Plugin source path | `D:\REPOS\MANGO\source\Nopcommerce.Common\4.70\Plugins\Nop.Plugin.Misc.AIPlugin` |
| Theme path | `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven` |
The plugin is called **AIPlugin** on disk (folder/csproj) but the assembly and namespace use `FruitBankPlugin`. Both names are the same thing.
---
## 2. Business Domain
FruitBank is a **Hungarian fruit and vegetable wholesale company** running a private B2B NopCommerce webshop. The typical user is a warehouse employee or admin working on mobile. Key business concepts:
- **Partners** business customers (companies), matched by name across multiple systems
- **Shipping documents** PDF/image documents received from suppliers, parsed by AI
- **Measurable products** products that require physical weighing before price is finalized; `IsMeasurable` is determined server-side only
- **Stock taking** periodic inventory audit workflow with discrepancy reports
- **InnVoice** external accounting/invoicing system, synced via `InnVoiceOrderService` / `InnVoiceApiService`
- **Voice ordering** warehouse staff dictate orders in Hungarian; transcribed via Whisper
---
## 3. Folder Structure
```
Nop.Plugin.Misc.AIPlugin/
├── Areas/Admin/
│ ├── Controllers/ # All admin-area controllers
│ ├── Components/ # Admin view components
│ ├── Factories/ # CustomOrderModelFactory, CustomProductModelFactory
│ ├── Models/ # Admin view models (extended Nop models)
│ ├── Validators/
│ └── Views/ # Admin Razor views; custom layouts: _FruitBankAdminLayout.cshtml
├── Controllers/ # Public-facing controllers (QuickOrder, Checkout, FruitBankData)
├── Components/ # Widget view components (ProductAI, ProductAttributes, OrderAttributes)
├── css/ / js/ # Static assets for the plugin
├── Domains/
│ └── DataLayer/ # LinqToDB table classes + DbContexts
│ ├── FruitBankDbContext.cs
│ ├── StockTakingDbContext.cs
│ └── *DbTable.cs # One file per custom table
├── Infrastructure/
│ ├── PluginNopStartup.cs # DI registration + SignalR + middleware
│ ├── RouteProvider.cs
│ ├── ViewLocationExpander.cs
│ └── FruitBankMessageTokenProvider.cs # Overrides IMessageTokenProvider
├── Services/ # Business logic services
├── Localization/
│ ├── quickorder.en.xml
│ └── quickorder.hu.xml
├── FruitBankPlugin.cs # Main plugin class (IWidgetPlugin)
├── FruitBankSettings.cs # Plugin settings (ApiKey etc.)
├── FruitBankConst.cs # Constants
└── plugin.json
```
---
## 4. Key Services
### AI / LLM
| Service | Purpose |
|---|---|
| `OpenAIApiService` | Primary OpenAI integration chat completions, Whisper transcription |
| `OpenAiService` | Lightweight wrapper using `gpt-4o-mini` for simple prompts |
| `CerebrasAPIService` | Alternative LLM provider |
| `ReplicateService` | Replicate.com API (image/audio models); registered with a hardcoded Bearer token |
| `AICalculationService` | AI-assisted price/measurement calculations |
### Storage
| Service | Purpose |
|---|---|
| `FileStorageService` | Generic file storage: SHA256 hash dedup, GZip compression, path building |
| `IFileStorageProvider` / `LocalFileStorageProvider` | Strategy pattern storage backend (currently local disk / wwwroot/uploads) |
**FileStorageService patterns:**
- Calculates SHA256 on upload BEFORE any AI processing → prevents duplicate API calls
- Skips GZip for already-compressed formats (jpg, pdf, mp4, zip, etc.)
- Path format: `{userId}/{featureName}/{entityType}-{entityId}/{fileName}_{id}.ext`
- DB record created first to get ID, then file is saved; rolled back on failure
### Order / Measurement
| Service | Purpose |
|---|---|
| `OrderMeasurementService` / `IOrderMeasurementService` | Handles orders that contain measurable products |
| `MeasurementService` / `IMeasurementService` | Core weighing logic |
| `InnVoiceOrderService` | Syncs orders with InnVoice accounting system |
| `InnVoiceApiService` | HTTP client for the InnVoice REST API |
### Infrastructure
| Service | Purpose |
|---|---|
| `FruitBankAttributeService` | Custom product/order attribute helpers |
| `LockService` / `ILockService` | Singleton distributed lock |
| `PdfToImageService` | Converts PDF pages to images for AI vision processing |
| `EventConsumer` | Handles `OrderPlacedEvent` |
| `FruitBankHub` | SignalR hub for real-time admin notifications |
---
## 5. Admin Controllers
| Controller | Purpose |
|---|---|
| `CustomOrderController` | Extended order management: **split order** feature (audit-based + manual selection modes), order notes, SignalR events |
| `CustomDashboardController` | AI-powered admin dashboard with `GetWelcomeMessageAsync` (store summary, order totals, stock discrepancies, OpenWeatherMap weather) |
| `ShippingController` | Shipping document management + AI PDF extraction workflow |
| `VoiceOrderController` | Voice-to-order admin tool (mobile-optimized) |
| `FruitBankAudioController` | Audio upload/processing endpoint for Whisper transcription |
| `InvoiceController` | Invoice generation and management |
| `InnVoiceOrderController` | InnVoice order sync UI |
| `InnVoiceOrderSyncController` | InnVoice sync API endpoints |
| `ManagementPageController` | General management page |
| `FileManagerController` + `FileManagerScriptsApiController` | File manager UI |
| `FileStorageController` | File storage API endpoints |
| `AppDownloadController` | App download/distribution page |
| `FruitBankPluginAdminController` | Plugin configuration page |
| `CustomProductController` | Extended product admin (IsMeasurable etc.) |
---
## 6. Public Controllers
| Controller | Route | Purpose |
|---|---|---|
| `QuickOrderController` | `/gyors-rendeles` | Customer-facing quick order page with voice + text search |
| `CheckoutController` | `/checkout/*` | Custom checkout flow override |
| `FruitBankDataController` | `/fruitbankdata/*` | Public data API endpoints; also implements `IFruitBankDataControllerServer` |
---
## 7. Widget Zones
The plugin registers widgets in:
- `PublicWidgetZones.ProductBoxAddinfoBefore``ProductAIWidgetViewComponent`
- `PublicWidgetZones.ProductDetailsBottom``ProductAIWidgetViewComponent`
- `AdminWidgetZones.ProductDetailsBlock``ProductAttributesViewComponent`
- `AdminWidgetZones.OrderDetailsBlock``OrderAttributesViewComponent`
---
## 8. DI Registration Patterns (PluginNopStartup)
Important overrides / replacements:
```csharp
// Replaces the default NopCommerce price calculator
services.Replace(ServiceDescriptor.Scoped<IPriceCalculationService, CustomPriceCalculationService>());
// Overrides email order table rendering
services.Replace(ServiceDescriptor.Scoped<IMessageTokenProvider, FruitBankMessageTokenProvider>());
// Overrides generic attribute service
services.AddScoped<IGenericAttributeService, GenericAttributeService>();
// Overrides order model and product model factories
services.AddScoped<IOrderModelFactory, CustomOrderModelFactory>();
services.AddScoped<IProductModelFactory, CustomProductModelFactory>();
// Overrides WorkflowMessageService (order emails)
services.AddScoped<IWorkflowMessageService, WorkflowMessageService>();
```
SignalR is configured with:
- MaximumReceiveMessageSize / StatefulReconnectBufferSize: 30 MB
- `DevAdminSignalRHub` on `/{FruitBankConstClient.DefaultHubName}` (WebSockets only)
- `LoggerSignalRHub` on `/{FruitBankConstClient.LoggerHubName}`
---
## 9. Database / Data Layer
Custom tables use **LinqToDB** (not EF Core) through wrapper DbTable classes registered in DI. Two DbContext wrappers:
- `FruitBankDbContext` main plugin data
- `StockTakingDbContext` stock taking workflow data
Key custom tables:
| DbTable class | Purpose |
|---|---|
| `PartnerDbTable` | Business partners (wholesale customers) |
| `ShippingDbTable` | Shipping records |
| `ShippingDocumentDbTable` | Parsed shipping document metadata |
| `ShippingItemDbTable` | Line items from shipping documents |
| `ShippingDocumentToFilesDbTable` | Junction: document ↔ file |
| `FilesDbTable` | Generic file records (hash, compression flag, raw text) |
| `OrderDtoDbTable` / `OrderItemDtoDbTable` | Order DTO projections |
| `OrderItemPalletDbTable` / `ShippingItemPalletDbTable` / etc. | Pallet tracking for measurement workflow |
| `StockTakingDbTable` / `StockTakingItemDbTable` | Stock audit records |
| `StockQuantityHistoryDtoDbTable` | Stock movement history |
| `MeasuringItemPalletBaseDbTable` | Base pallet measuring data |
**N+1 query prevention:** Always batch DB calls with `Task.WhenAll`. Never query per-item inside a loop.
---
## 10. Localization
All resource keys follow the prefix: `Plugins.Misc.FruitBankPlugin.*`
- Keys are registered programmatically in `FruitBankPlugin.InstallAsync()` for **both EN and HU**
- XML locale files in `/Localization/`: `quickorder.en.xml`, `quickorder.hu.xml`
- When adding new keys: update **all three places** (InstallAsync + both XML files)
- Hungarian is the **primary** language; English is secondary
- Use `_localizationService.AddOrUpdateLocaleResourceAsync("key", "value", "HU")` pattern
Common key prefixes:
- `Plugins.Misc.FruitBankPlugin.Menu.*` navigation
- `Plugins.Misc.FruitBankPlugin.QuickOrder.*` quick order page (extensive set)
---
## 11. Quick Order Page (`/gyors-rendeles`)
**Controller:** `QuickOrderController`
**View:** `/Views/QuickOrder/`
**CSS:** `/css/quick-order.css` (in plugin) + deployed to CarHaven theme
Design system tokens (CarHaven theme):
```css
--theme-color: #2d7a3a /* green */
--active-color: #f4a236 /* amber */
--dark: #1a3c22
--light-bg: #f5f7f2
font-family: 'DM Sans'
border-radius: 8px
```
Product cards use full-width flex rows: `.product-card { flex-direction: row }` with `.pc-body` (left, grows) and `.pc-actions` (right, fixed).
Navigation menu integration:
- CarHaven `TopMenu/Default.cshtml` has a `<li class="quick-order-menu-item">` for both desktop (`.notmobile`) and mobile (`.mobile`) menu blocks
- Guarded by `@if (Model.DisplayCustomerInfoMenuItem)` (login-gated)
- Menu item styled in `quick-order-menu.css` (amber, bold) included via `Head.cshtml`
- Uses `fa fa-bolt` icon
Voice input:
- Records audio in browser, POSTs to `FruitBankAudioController`
- Whisper transcription with Hungarian vocabulary hints (partner names + produce terms)
- **Prompt character limit is 224** use keyword extraction, not full company names
- Fallback: manual text search input
---
## 12. Split Order Feature
Admin page on order detail. Two modes selectable via radio buttons:
| Mode | Behaviour |
|---|---|
| **Audit-based** | Available only when order has both "started" and "non-started" audit items. Audited items stay; non-audited items move to new order. |
| **Manual selection** | Always available (except for fully audited orders). Checkbox per item; user chooses what moves. |
Split button is always enabled (except audited orders). Mode availability is communicated visually if a mode is disabled.
**Critical lesson:** `TransactionSafeAsync` caused deadlocks because `TaskHelper.ToThreadPoolTask` creates async/await context switching in ASP.NET. The transaction wrapper was removed. Avoid wrapping split logic in `TransactionSafeAsync`.
After split: inventory adjustments, order notes written, SignalR notification sent to admin clients.
---
## 13. AI Admin Dashboard (`GetWelcomeMessageAsync`)
Located in `CustomDashboardController`. Generates a structured OpenAI prompt containing:
- Store data summary
- Today's order totals
- Stock discrepancy summary (from stock taking audit)
- OpenWeatherMap weather data (real API key configured in settings)
Patterns used:
- Typed C# records for data transfer
- `Task.WhenAll` for parallel DB calls
- Batched product history queries (no N+1)
- Bilingual (Hungarian/English) system prompt with JSON field guide for the AI
- `salesAdjustmentSum` be careful not to double-count
---
## 14. Shipping Document Processing
AI-driven workflow for extracting partner + product data from uploaded PDFs/images:
1. File uploaded → SHA256 hash calculated **before AI call**
2. Hash checked against DB → if duplicate, load existing data (skip AI, save API cost)
3. PDF converted to image(s) via `PdfToImageService` if needed
4. OpenAI vision API extracts structured product/partner data
5. Multi-stage matching: string search → historical shipping data → AI semantic match
6. UI shows visual matched/unmatched indicators + autocomplete for manual correction
7. `IsMeasurable` is **server-side only** never expose in frontend forms
---
## 15. NopCommerce 4.80 Gotchas
| Issue | Correct approach |
|---|---|
| `ICustomerAttributeService` does not exist | Use direct `XDocument.Parse` on the XML stored in `GenericAttribute.Value` |
| `ParseAttributeValuesAsync` returns empty for free-text attributes | It's designed for predefined selection attributes (ID lookup). For free-text: parse XML directly: `<Attributes><CustomerAttribute ID="1"><CustomerAttributeValue><Value>...</Value>...` |
| `TransactionSafeAsync` + async = deadlock | `TaskHelper.ToThreadPoolTask` inside it causes context switching deadlocks in ASP.NET; remove transaction wrapper for affected code |
| Email order table customization | Override `IMessageTokenProvider` with `FruitBankMessageTokenProvider` (already done); base class constructor has many parameters pass all through exactly |
| `OrderPlaced.CustomerNotification` email template | Configured in NopCommerce admin under Content → Message Templates; code hook is `WorkflowMessageService.SendOrderPlacedCustomerNotificationAsync` |
---
## 16. Theme (CarHaven)
Path: `D:\REPOS\MANGO\source\FruitBank\Presentation\Nop.Web\Themes\CarHaven`
Relevant files modified by this plugin's work:
- `TopMenu/Default.cshtml` quick order nav item added
- `Head.cshtml` includes `quick-order-menu.css`
- `css/quick-order-menu.css` amber nav item styling
---
## 17. External APIs / Credentials
| Service | Usage | Notes |
|---|---|---|
| OpenAI | Chat completions (gpt-4o-mini / gpt-4o), Whisper transcription | API key in `FruitBankSettings.ApiKey` |
| OpenWeatherMap | Weather data for dashboard welcome message | Real API key confirmed in settings |
| Replicate | Image/audio AI models | Bearer token hardcoded in `PluginNopStartup` HTTP client registration |
| InnVoice | Hungarian accounting/invoicing system | REST API via `InnVoiceApiService` |
---
## 18. Conventions & Patterns to Follow
1. **Always bilingual** every new locale resource key goes into InstallAsync (EN + HU) and both XML files.
2. **Mobile-first** for warehouse tools large touch targets, step-by-step UX, pulse animations.
3. **Server-side business rules** never expose `IsMeasurable` or similar computed flags in frontend forms.
4. **Batch DB calls** use `Task.WhenAll`, never query inside a loop.
5. **Hash before AI** always deduplicate files by SHA256 hash before calling any AI API.
6. **CarHaven design tokens** use CSS variables (`--theme-color`, `--active-color`, `--dark`, `--light-bg`, `DM Sans` font) consistently.
7. **No `TransactionSafeAsync`** on code paths that use async/await through thread pool.
8. **Expect corrections** Adam knows the codebase deeply; treat any correction as authoritative and apply it without re-litigating.
---
*Last updated: 2026-03-18 | Maintained by: Claude (auto-generated from codebase + project chat history)*

View File

@ -26,6 +26,7 @@ public class CustomPriceCalculationService : PriceCalculationService
private readonly IProductAttributeService _productAttributeService;
private readonly ISpecificationAttributeService _specificationAttributeService;
private readonly ILocalizationService _localizationService;
private readonly IStoreContext _storeContext;
private ILogger _logger;
public CustomPriceCalculationService(
@ -46,7 +47,8 @@ public class CustomPriceCalculationService : PriceCalculationService
ILocalizationService localizationService,
IStaticCacheManager cacheManager,
IWorkContext workContext,
IEnumerable<IAcLogWriterBase> logWriters)
IEnumerable<IAcLogWriterBase> logWriters,
IStoreContext storeContext)
: base(catalogSettings, currencySettings, categoryService, currencyService, customerService, discountService, manufacturerService,
productAttributeParser, productService,
cacheManager)
@ -58,6 +60,7 @@ public class CustomPriceCalculationService : PriceCalculationService
_productAttributeService = productAttributeService;
_specificationAttributeService = specificationAttributeService;
_localizationService = localizationService;
_storeContext = storeContext;
}
public static decimal CalculateOrderItemFinalPrice(bool isMeasurable, decimal unitPrice, int quantity, double netWeight)
@ -135,9 +138,26 @@ public class CustomPriceCalculationService : PriceCalculationService
_logger.Info($"order.OrderTotal({order.OrderTotal}) == prevOrderTotal({prevOrderTotal})");
order.OrderSubtotalInclTax = order.OrderTotal;
order.OrderSubtotalExclTax = order.OrderTotal;
order.OrderSubTotalDiscountInclTax = order.OrderTotal;
order.OrderSubTotalDiscountExclTax = order.OrderTotal;
order.OrderSubtotalExclTax = (order.OrderTotal / (decimal)1.27);
//mivel csak csekkolunk, de nem adunk vissza semmilyen kedvezményt, így a subtotal discount értékek kiszámolááshoz meg kell hívni megint a calculate final price-t
decimal orderSubTotalDiscountInclTax = 0;
decimal orderSubTotalDiscountExclTax = 0;
var store = await _storeContext.GetCurrentStoreAsync();
foreach (var orderItem in orderItems)
{
var orderItemDto = orderItemDtosById[orderItem.Id];
var product = await _dbContext.Products.GetByIdAsync(orderItem.ProductId);
var customer = await _dbContext.Customers.GetByIdAsync(order.CustomerId);
var itemPrice = await GetFinalPriceAsync(product, customer, store, 0, true, orderItemDto.Quantity);
orderSubTotalDiscountInclTax += itemPrice.appliedDiscountAmount;
orderSubTotalDiscountExclTax += itemPrice.appliedDiscountAmount / (decimal)1.27;
}
order.OrderSubTotalDiscountInclTax = orderSubTotalDiscountInclTax;
order.OrderSubTotalDiscountExclTax = orderSubTotalDiscountExclTax;
await _dbContext.Orders.UpdateAsync(order, false);
return true;

View File

@ -0,0 +1,71 @@
using FruitBank.Common.Entities;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Payments;
using Nop.Data;
using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public class CustomerCreditService : ICustomerCreditService
{
private readonly CustomerCreditDbTable _customerCreditDbTable;
private readonly IRepository<Order> _orderRepository;
public CustomerCreditService(
CustomerCreditDbTable customerCreditDbTable,
IRepository<Order> orderRepository)
{
_customerCreditDbTable = customerCreditDbTable;
_orderRepository = orderRepository;
}
public Task<CustomerCredit?> GetByCustomerIdAsync(int customerId)
=> _customerCreditDbTable.GetByCustomerIdAsync(customerId);
public async Task SaveAsync(CustomerCredit entity)
{
entity.UpdatedOnUtc = DateTime.UtcNow;
if (entity.Id <= 0)
{
entity.CreatedOnUtc = DateTime.UtcNow;
await _customerCreditDbTable.InsertAsync(entity);
}
else
{
await _customerCreditDbTable.UpdateAsync(entity);
}
}
public Task DeleteAsync(CustomerCredit entity)
=> _customerCreditDbTable.DeleteAsync(entity);
public async Task<decimal> GetOutstandingBalanceAsync(int customerId)
{
return await _orderRepository.Table
.Where(o =>
o.CustomerId == customerId &&
o.OrderStatusId != (int)OrderStatus.Cancelled &&
(o.PaymentStatusId == (int)PaymentStatus.Pending ||
o.PaymentStatusId == (int)PaymentStatus.PartiallyRefunded))
.SumAsync(o => (decimal?)o.OrderTotal) ?? 0m;
}
public async Task<decimal?> GetRemainingCreditAsync(int customerId)
{
var credit = await GetByCustomerIdAsync(customerId);
if (credit == null) return null;
var outstanding = await GetOutstandingBalanceAsync(customerId);
return credit.CreditLimit - outstanding;
}
public async Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal)
{
var credit = await GetByCustomerIdAsync(customerId);
if (credit == null) return true;
var outstanding = await GetOutstandingBalanceAsync(customerId);
return outstanding + newOrderTotal <= credit.CreditLimit;
}
}

View File

@ -1,4 +1,5 @@
using FruitBank.Common.Interfaces;
using FruitBank.Common.Enums;
using FruitBank.Common.Interfaces;
using FruitBank.Common.Server;
using Mango.Nop.Core.Dtos;
using Microsoft.AspNetCore.Http;
@ -8,6 +9,7 @@ using Nop.Core;
using Nop.Core.Domain.Catalog;
using Nop.Core.Domain.Common;
using Nop.Core.Domain.Customers;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders;
using Nop.Core.Domain.Tax;
using Nop.Core.Events;
@ -20,11 +22,13 @@ using Nop.Services.Common;
using Nop.Services.Customers;
using Nop.Services.Events;
using Nop.Services.Localization;
using Nop.Services.Messages;
using Nop.Services.Orders;
using Nop.Services.Plugins;
using Nop.Web.Framework.Events;
using Nop.Web.Framework.Menu;
using Nop.Web.Models.Sitemap;
using NUglify.JavaScript.Syntax;
using System.Linq;
using System.Xml.Linq;
@ -47,6 +51,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
private readonly FruitBankDbContext _dbContext;
private readonly IAttributeParser<CustomerAttribute, CustomerAttributeValue> _attributeParser;
private readonly ICustomerService _customerService;
private readonly IWorkflowMessageService _workflowMessageService;
private readonly FruitBankNotificationService _fruitBankNotificationService;
public EventConsumer(
IGenericAttributeService genericAttributeService,
@ -64,7 +70,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
FruitBankAttributeService fruitBankAttributeService,
FruitBankDbContext dbContext,
IAttributeParser<CustomerAttribute, CustomerAttributeValue> attributeParser,
ICustomerService customerService
ICustomerService customerService,
IWorkflowMessageService workflowMessageService,
FruitBankNotificationService fruitBankNotificationService
) : base(pluginManager)
{
_genericAttributeService = genericAttributeService;
@ -82,6 +90,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
_dbContext = dbContext;
_attributeParser = attributeParser;
_customerService = customerService;
_workflowMessageService = workflowMessageService;
_fruitBankNotificationService = fruitBankNotificationService;
}
protected override string PluginSystemName => "Misc.FruitBankPlugin";
@ -93,6 +103,43 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
public async Task HandleEventAsync(EntityUpdatedEvent<Order> eventMessage)
{
await SaveOrderCustomAttributesAsync(eventMessage.Entity);
if (eventMessage.Entity == null) return;
var orderDto = await _dbContext.OrderDtos.GetByIdAsync(eventMessage.Entity.Id, true);
if (orderDto == null) return;
if (orderDto.MeasuringStatus == MeasuringStatus.Audited)
{
var alreadySent = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderAuditedNotificationSent");
if (!alreadySent)
{
await _fruitBankNotificationService
.SendOrderAuditedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
eventMessage.Entity.Id, "OrderAuditedNotificationSent", true);
}
}
else if (orderDto.MeasuringStatus == MeasuringStatus.Started)
{
var alreadySent = await _fruitBankAttributeService
.GetGenericAttributeValueAsync<Order, bool>(eventMessage.Entity.Id, "OrderStartedNotificationSent");
if (!alreadySent)
{
await _fruitBankNotificationService
.SendOrderStartedCustomerNotificationAsync(eventMessage.Entity, orderDto.IsMeasurable);
await _fruitBankAttributeService
.InsertOrUpdateGenericAttributeAsync<Order, bool>(
eventMessage.Entity.Id, "OrderStartedNotificationSent", true);
}
}
}
@ -182,10 +229,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
IconClass = "fas fa-microphone",
Url = _adminMenu.GetMenuItemUrl("VoiceOrder", "Create")
//ChildNodes = [shippingsListMenuItem, createShippingMenuItem, editShippingMenuItem]
};
shippingConfigurationItem.ChildNodes.Insert(3, voiceOrderMenuItem);

View File

@ -0,0 +1,84 @@
using Nop.Core;
using Nop.Core.Domain.Messages;
using Nop.Core.Domain.Orders;
using Nop.Services.Customers;
using Nop.Services.Messages;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public class FruitBankNotificationService(
IMessageTemplateService messageTemplateService,
IEmailAccountService emailAccountService,
EmailAccountSettings emailAccountSettings,
IMessageTokenProvider messageTokenProvider,
IWorkflowMessageService workflowMessageService,
ICustomerService customerService,
IStoreContext storeContext)
{
public const string ORDER_AUDITED_TEMPLATE_NAME = "FruitBank.OrderAudited.CustomerNotification";
public const string ORDER_STARTED_TEMPLATE_NAME = "FruitBank.OrderStarted.CustomerNotification";
/// <summary>
/// Sends the "order started" (being prepared) customer notification.
/// For measurable orders, informs the customer that final prices will be
/// confirmed after weighing. Fires once when MeasuringStatus transitions to Started.
/// </summary>
public async Task<int> SendOrderStartedCustomerNotificationAsync(Order order, bool isMeasurable)
{
var measurableNote = isMeasurable
? "<p>Rendel&#233;sed m&#233;rhet&#337; t&#233;teleket tartalmaz. A v&#233;gleges &#225;r a m&#233;r&#233;s ut&#225;n ker&#252;l meger&#337;s&#237;t&#233;sre.</p>"
: string.Empty;
return await SendNotificationAsync(ORDER_STARTED_TEMPLATE_NAME, order, measurableNote);
}
/// <summary>
/// Sends the "order audited" customer notification.
/// For measurable orders, confirms that weights have been recorded and
/// the final price is as shown on the order.
/// Fires once when MeasuringStatus transitions to Audited.
/// </summary>
public async Task<int> SendOrderAuditedCustomerNotificationAsync(Order order, bool isMeasurable)
{
var measurableNote = isMeasurable
? "<p>A m&#233;rt t&#233;telek s&#250;lyait r&#246;gz&#237;tett&#252;k, a v&#233;gleges &#225;r a rendel&#233;sen felt&#252;ntetett &#246;sszeg.</p>"
: string.Empty;
return await SendNotificationAsync(ORDER_AUDITED_TEMPLATE_NAME, order, measurableNote);
}
// ── shared core ─────────────────────────────────────────────────────────
private async Task<int> SendNotificationAsync(string templateName, Order order, string measurableNote)
{
var store = await storeContext.GetCurrentStoreAsync();
var templates = await messageTemplateService.GetMessageTemplatesByNameAsync(templateName, store.Id);
var messageTemplate = templates?.FirstOrDefault();
if (messageTemplate is null || !messageTemplate.IsActive)
return 0;
var emailAccount = await emailAccountService.GetEmailAccountByIdAsync(messageTemplate.EmailAccountId)
?? await emailAccountService.GetEmailAccountByIdAsync(emailAccountSettings.DefaultEmailAccountId);
var tokens = new List<Token>();
await messageTokenProvider.AddStoreTokensAsync(tokens, store, emailAccount, order.CustomerLanguageId);
await messageTokenProvider.AddOrderTokensAsync(tokens, order, order.CustomerLanguageId);
var customer = await customerService.GetCustomerByIdAsync(order.CustomerId);
await messageTokenProvider.AddCustomerTokensAsync(tokens, customer);
tokens.Add(new Token("Order.MeasurableNote", measurableNote, true));
var toEmail = customer.Email;
var toName = $"{customer.FirstName} {customer.LastName}".Trim();
if (string.IsNullOrWhiteSpace(toName)) toName = customer.Email;
return await workflowMessageService.SendNotificationAsync(
messageTemplate, emailAccount,
order.CustomerLanguageId,
tokens,
toEmail, toName);
}
}

View File

@ -0,0 +1,32 @@
using FruitBank.Common.Entities;
namespace Nop.Plugin.Misc.FruitBankPlugin.Services;
public interface ICustomerCreditService
{
/// <summary>Gets the credit record for a customer, or null if none exists (= unlimited).</summary>
Task<CustomerCredit?> GetByCustomerIdAsync(int customerId);
/// <summary>Insert or update a customer credit record.</summary>
Task SaveAsync(CustomerCredit entity);
/// <summary>Delete the credit record for a customer, restoring unlimited access.</summary>
Task DeleteAsync(CustomerCredit entity);
/// <summary>
/// Sum of OrderTotal for all pending/unpaid, non-cancelled orders for the customer.
/// </summary>
Task<decimal> GetOutstandingBalanceAsync(int customerId);
/// <summary>
/// CreditLimit - OutstandingBalance. Returns null if no credit record exists (= unlimited).
/// </summary>
Task<decimal?> GetRemainingCreditAsync(int customerId);
/// <summary>
/// Returns true if the customer is allowed to place a new order with the given total.
/// Rule: no credit record = always allowed.
/// Otherwise: OutstandingBalance + newOrderTotal must be &lt;= CreditLimit.
/// </summary>
Task<bool> IsOrderAllowedAsync(int customerId, decimal newOrderTotal);
}

View File

@ -0,0 +1,83 @@
@model Nop.Plugin.Misc.FruitBankPlugin.Models.CustomerCreditWidgetModel
@{
var remaining = Model.RemainingCredit;
var statusClass = !Model.HasCreditLimit ? "text-muted"
: remaining <= 0 ? "text-danger"
: remaining < Model.CreditLimit * 0.2m ? "text-warning"
: "text-success";
}
<div class="card card-default">
<div class="card-header">
<i class="fas fa-credit-card"></i>
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.PageTitle")
</div>
<div class="card-body">
<div class="form-group row">
<div class="col-md-3">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.CreditLimit")</label>
</div>
<div class="col-md-9">
<span class="form-control-plaintext">
@(Model.HasCreditLimit ? Model.CreditLimit.ToString("N0") + " Ft" : T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited").Text)
</span>
</div>
</div>
<div class="form-group row">
<div class="col-md-3">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.OutstandingBalance")</label>
</div>
<div class="col-md-9">
<span class="form-control-plaintext">
@Model.OutstandingBalance.ToString("N0") Ft
</span>
</div>
</div>
<div class="form-group row">
<div class="col-md-3">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.RemainingCredit")</label>
</div>
<div class="col-md-9">
<span class="form-control-plaintext @statusClass">
<strong>
@if (!Model.HasCreditLimit)
{
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Unlimited")
}
else
{
@(remaining!.Value.ToString("N0"))
<span>Ft</span>
}
</strong>
</span>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Comment))
{
<div class="form-group row">
<div class="col-md-3">
<label>@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.Comment")</label>
</div>
<div class="col-md-9">
<span class="form-control-plaintext text-muted">@Model.Comment</span>
</div>
</div>
}
<div class="form-group row">
<div class="col-md-9 offset-md-3">
<a href="/Admin/CustomerCredit/Details/@Model.CustomerId" class="btn btn-default">
<i class="fas fa-edit"></i>
@T("Plugins.Misc.FruitBankPlugin.CustomerCredit.EditTitle")
</a>
</div>
</div>
</div>
</div>

View File

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

View File

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