AI document parsing small fixes (ismeasurable fix)

ShippingItem.UnitCostOnDocument added
other developments
This commit is contained in:
Adam 2025-12-19 01:43:51 +01:00
parent eb40643d62
commit 365c911c11
5 changed files with 1784 additions and 446 deletions

View File

@ -207,7 +207,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
Console.WriteLine($"Product analysis Result: {deserializedContent.extractedData.products}");
//identify products from database
var allProducts = await _dbContext.ProductDtos.GetAll().ToListAsync();
var allProducts = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
var historicalProducts = await _dbContext.ShippingItems.GetAll().ToListAsync();
//create json from product analyzis jsonstring
@ -300,7 +300,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
private async Task<List<ShippingItem>> DetermineProducts(List<ProductDto> allProducts, List<ShippingItem> historicalProducts, ProductReferenceResponse deserializedProducts)
{
List<ShippingItem> finalMatchedProducts = new List<ShippingItem>();
//List<ProductDto> allProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
// Load all shipping items once
var allShippingItems = await _dbContext.ShippingItems.GetAll(true).ToListAsync();
@ -316,7 +316,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
if (historicalProduct != null)
{
Console.WriteLine($"Historical product found: {historicalProduct.Name}");
var productDto = await _dbContext.ProductDtos.GetByIdAsync(historicalProduct.ProductId);
var productDto = allProducts.FirstOrDefault(p => p.Id == historicalProduct.ProductId);
if (productDto != null)
{
@ -542,7 +542,9 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
NetWeightOnDocument = deserializedProduct.netWeight ?? 0,
GrossWeightOnDocument = deserializedProduct.grossWeight ?? 0,
ProductId = productDto.Id,
NameOnDocument = deserializedProduct.name
NameOnDocument = deserializedProduct.name,
IsMeasurable = productDto.IsMeasurable,
UnitPriceOnDocument = deserializedProduct.unitCost
};
}
@ -894,6 +896,7 @@ namespace Nop.Plugin.Misc.FruitBank.Controllers
public double? netWeight { get; set; }
public double? grossWeight { get; set; }
public int? productId { get; set; }
public double unitCost { get; set; }
}
public class ProductReferenceResponse

View File

@ -70,15 +70,69 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
try
{
// Transcribe audio
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
// Build partner names prompt for Whisper vocabulary hints
// Whisper has a 224 character limit, so extract unique KEYWORDS instead of full names
var allCustomers = await _customerService.GetAllCustomersAsync(pageIndex: 0, pageSize: 300);
var companyNames = allCustomers
.Where(c => !string.IsNullOrEmpty(c.Company))
.Select(c => c.Company.Trim())
.Distinct()
.ToList();
// Extract unique keywords from company names
var keywords = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var company in companyNames)
{
// Split by common separators and extract meaningful words
var words = company.Split(new[] { ' ', ',', '.', '-', '/', '(', ')' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var word in words)
{
var cleaned = word.Trim();
// Skip very short words, common abbreviations, and legal terms
if (cleaned.Length < 3) continue;
if (cleaned.Equals("BV", StringComparison.OrdinalIgnoreCase)) continue;
if (cleaned.Equals("Ltd", StringComparison.OrdinalIgnoreCase)) continue;
if (cleaned.Equals("Kft", StringComparison.OrdinalIgnoreCase)) continue;
if (cleaned.Equals("Inc", StringComparison.OrdinalIgnoreCase)) continue;
if (cleaned.Equals("GmbH", StringComparison.OrdinalIgnoreCase)) continue;
if (cleaned.Equals("SRL", StringComparison.OrdinalIgnoreCase)) continue;
keywords.Add(cleaned);
}
}
// Build prompt from keywords, fitting as many as possible in 224 chars
var keywordList = keywords.OrderBy(k => k.Length).ToList();
var promptParts = new List<string>();
int currentLength = 0;
const int maxLength = 220;
foreach (var keyword in keywordList)
{
var toAdd = promptParts.Count == 0 ? keyword : ", " + keyword;
if (currentLength + toAdd.Length > maxLength)
break;
promptParts.Add(keyword);
currentLength += toAdd.Length;
}
var partnerPrompt = string.Join(", ", promptParts);
Console.WriteLine($"[VoiceOrder] Whisper prompt with {promptParts.Count} keywords from {companyNames.Count} partners ({partnerPrompt.Length} chars)");
// Transcribe audio in HUNGARIAN with partner keywords as vocabulary hints
var transcribedText = await TranscribeAudioFile(audioFile, "hu", partnerPrompt);
if (string.IsNullOrEmpty(transcribedText))
{
return Json(new { success = false, message = "Failed to transcribe audio" });
}
Console.WriteLine($"[VoiceOrder] Partner transcription: {transcribedText}");
Console.WriteLine($"[VoiceOrder] Partner transcription (HU): {transcribedText}");
// Search for matching partners
var partners = await SearchPartners(transcribedText);
@ -113,7 +167,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
try
{
// Transcribe audio
// Transcribe audio in HUNGARIAN
var transcribedText = await TranscribeAudioFile(audioFile, "hu");
if (string.IsNullOrEmpty(transcribedText))
@ -121,7 +175,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return Json(new { success = false, message = "Failed to transcribe audio" });
}
Console.WriteLine($"[VoiceOrder] Product transcription: {transcribedText}");
Console.WriteLine($"[VoiceOrder] Product transcription (HU): {transcribedText}");
// Parse products and quantities using AI
var parsedProducts = await ParseProductsFromText(transcribedText);
@ -153,12 +207,95 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
}
}
/// <summary>
/// Search for partners by manually typed text (no audio transcription needed)
/// </summary>
[HttpPost]
public async Task<IActionResult> SearchPartnerByText(string text)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrWhiteSpace(text))
{
return Json(new { success = false, message = "No text provided" });
}
try
{
Console.WriteLine($"[VoiceOrder] Manual partner search: {text}");
// Search for matching partners (same logic as voice)
var partners = await SearchPartners(text);
return Json(new
{
success = true,
transcription = text,
partners = partners
});
}
catch (Exception ex)
{
Console.WriteLine($"[VoiceOrder] Error in SearchPartnerByText: {ex.Message}");
return Json(new { success = false, message = $"Error: {ex.Message}" });
}
}
/// <summary>
/// Parse manually typed product text (no audio transcription needed)
/// </summary>
[HttpPost]
public async Task<IActionResult> ParseManualProductText(string text)
{
if (!await _permissionService.AuthorizeAsync(StandardPermission.Security.ACCESS_ADMIN_PANEL))
return Json(new { success = false, message = "Access denied" });
if (string.IsNullOrWhiteSpace(text))
{
return Json(new { success = false, message = "No text provided" });
}
try
{
Console.WriteLine($"[VoiceOrder] Manual product input: {text}");
// Parse products and quantities using AI (same as voice)
var parsedProducts = await ParseProductsFromText(text);
if (parsedProducts == null || parsedProducts.Count == 0)
{
return Json(new
{
success = false,
message = "Could not parse products from text",
transcription = text
});
}
// Enrich with actual product data from database
var enrichedProducts = await EnrichProductData(parsedProducts);
return Json(new
{
success = true,
transcription = text,
products = enrichedProducts
});
}
catch (Exception ex)
{
Console.WriteLine($"[VoiceOrder] Error in ParseManualProductText: {ex.Message}");
return Json(new { success = false, message = $"Error: {ex.Message}" });
}
}
#region Helper Methods
/// <summary>
/// Transcribe audio file using OpenAI Whisper
/// </summary>
private async Task<string> TranscribeAudioFile(IFormFile audioFile, string language)
private async Task<string> TranscribeAudioFile(IFormFile audioFile, string language, string customPrompt = null)
{
var fileName = $"voice_order_{DateTime.Now:yyyyMMdd_HHmmss}.webm";
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads", "voice");
@ -180,7 +317,7 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
string transcribedText;
using (var audioStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language);
transcribedText = await _aiApiService.TranscribeAudioAsync(audioStream, fileName, language, customPrompt);
}
// Clean up temporary file
@ -195,16 +332,15 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
/// <summary>
/// Search for partners matching the transcribed text
/// Uses string-based search first, then semantic AI matching if needed
/// Uses both string-based and AI semantic search for best results
/// </summary>
private async Task<List<object>> SearchPartners(string searchTerm)
{
const int maxResults = 10;
const int minResultsForAI = 3; // If we get fewer than this, use AI semantic search
Console.WriteLine($"[VoiceOrder] Searching partners for: {searchTerm}");
// Step 1: Try string-based search
// Step 1: String-based search (fast, catches exact matches)
var customersByCompany = await _customerService.GetAllCustomersAsync(
company: searchTerm,
pageIndex: 0,
@ -220,32 +356,29 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
pageIndex: 0,
pageSize: maxResults);
// Combine and deduplicate
var allCustomers = customersByCompany
// Combine string search results
var stringResults = customersByCompany
.Union(customersByName)
.Union(customersByLastName)
.DistinctBy(c => c.Id)
.Take(maxResults)
.ToList();
Console.WriteLine($"[VoiceOrder] String-based search found {allCustomers.Count} partners");
Console.WriteLine($"[VoiceOrder] String-based search found {stringResults.Count} partners");
// Step 2: If we don't have enough results, use AI semantic matching
if (allCustomers.Count < minResultsForAI)
{
Console.WriteLine("[VoiceOrder] Using AI semantic matching for partners");
// Step 2: ALWAYS use AI semantic search for better results
Console.WriteLine("[VoiceOrder] Using AI semantic matching for partners");
var aiMatches = await SemanticPartnerSearch(searchTerm);
Console.WriteLine($"[VoiceOrder] AI semantic search found {aiMatches.Count} partners");
var aiMatches = await SemanticPartnerSearch(searchTerm);
// Step 3: Merge results - string matches first (exact), then AI matches
var allCustomers = stringResults
.Union(aiMatches)
.DistinctBy(c => c.Id)
.Take(maxResults)
.ToList();
// Merge AI matches with string matches, remove duplicates
allCustomers = allCustomers
.Union(aiMatches)
.DistinctBy(c => c.Id)
.Take(maxResults)
.ToList();
Console.WriteLine($"[VoiceOrder] After AI matching: {allCustomers.Count} partners");
}
Console.WriteLine($"[VoiceOrder] Total unique partners: {allCustomers.Count}");
// Format results
var result = new List<object>();
@ -278,10 +411,10 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
{
try
{
// Get all customers with company names (limit to reasonable number)
// Get all customers with company names (increased limit)
var allCustomersWithCompany = await _customerService.GetAllCustomersAsync(
pageIndex: 0,
pageSize: 500); // Reasonable limit for AI processing
pageSize: 1000); // Increased from 500 to catch more companies
// Filter to only those with company names
var customersWithCompany = allCustomersWithCompany
@ -294,28 +427,35 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Areas.Admin.Controllers
return new List<Nop.Core.Domain.Customers.Customer>();
}
Console.WriteLine($"[VoiceOrder] AI searching through {customersWithCompany.Count} companies");
// Build company list for AI
var companyList = string.Join("\n", customersWithCompany
.Select((c, index) => $"{index}|{c.Company}"));
var systemPrompt = @"You are a company name matcher for a B2B system.
Given a spoken company name and a list of company names, find the 5 best semantic matches.
Given a spoken company name and a list of company names, find the 5 best matches.
RULES:
1. Consider phonetic similarity (how it sounds)
2. Consider abbreviations (e.g., 'SFI' matches 'SFI Rotterdam B.V.')
3. Consider partial matches (e.g., 'Rotterdam' matches 'SFI Rotterdam B.V.')
4. Consider common misspellings or mishearings
5. Return ONLY valid JSON array with indices, no explanations
CRITICAL MATCHING RULES (in priority order):
1. EXACT MATCH: If the search term appears exactly in a company name, prioritize it
2. SUBSTRING MATCH: If the search term is contained within a company name (e.g., 'Junket' in 'Junket Silver Kft.')
3. WORD MATCH: If all words from search term appear in company name (any order)
4. PARTIAL MATCH: If significant words overlap (e.g., 'Silver' matches 'Junket Silver')
5. PHONETIC SIMILARITY: How it sounds when spoken
6. ABBREVIATIONS: 'SFI' matches 'SFI Rotterdam B.V.'
INPUT FORMAT:
Search term: [spoken company name]
Companies: [index]|[company name] (one per line)
EXAMPLES:
Search: 'Junket Silver'
Should match: 'Junket Silver Kft.' (substring match - VERY HIGH PRIORITY)
Search: 'Rotterdam'
Should match: 'SFI Rotterdam B.V.' (substring match)
Return ONLY a JSON array with the top 5 indices, ordered by best match first.
If fewer than 5 matches exist, return fewer indices.
OUTPUT FORMAT (JSON only):
[0, 15, 42, 103, 256]
Return the top 5 indices that best match the search term. If fewer than 5 good matches exist, return fewer indices.";
[0, 15, 42, 103, 256]";
var userPrompt = $@"Search term: {searchTerm}
Companies:
@ -364,28 +504,51 @@ Companies:
/// </summary>
private async Task<List<ParsedProduct>> ParseProductsFromText(string text)
{
var systemPrompt = @"You are a product parser for a Hungarian fruit wholesale company.
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 speech.
RULES:
1. Extract product names and quantities
2. Normalize product names to singular, lowercase (e.g., 'narancsok' 'narancs')
3. Convert quantity units to standard format (kg, db, láda)
4. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, etc.)
5. Return ONLY valid JSON array, no explanations
CRITICAL RULES:
1. Extract product names and quantities from ANY produce item (fruits, vegetables, herbs, etc.)
2. Normalize product names to singular, lowercase (e.g., 'narancsok' 'narancs', 'áfonyák' 'áfonya')
3. Handle Hungarian number words ('száz' = 100, 'ötven' = 50, 'húsz' = 20, 'tíz' = 10, 'öt' = 5, 'egy' = 1, etc.)
4. FIX COMMON TRANSCRIPTION ERRORS:
- 'datója' 'datolya' (dates)
- 'szűlő' 'szőlő' (grapes)
- 'mondarin' 'mandarin' (mandarin)
- 'paprika' is correct (pepper/paprika)
- 'fokhagyma' is correct (garlic)
- Any obvious typo correct it
5. Return ONLY valid JSON array, no explanations or empty arrays
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, ""unit"": ""kg""},
{""product"": ""alma"", ""quantity"": 50, ""unit"": ""kg""}
{""product"": ""narancs"", ""quantity"": 100},
{""product"": ""alma"", ""quantity"": 50}
]
EXAMPLES:
Input: 'narancs száz kilogram és alma ötven kiló'
Output: [{""product"":""narancs"",""quantity"":100,""unit"":""kg""},{""product"":""alma"",""quantity"":50,""unit"":""kg""}]
Output: [{""product"":""narancs"",""quantity"":100},{""product"":""alma"",""quantity"":50}]
Input: 'Kérek 200 kg narancsot meg 150 kg almát'
Output: [{""product"":""narancs"",""quantity"":200,""unit"":""kg""},{""product"":""alma"",""quantity"":150,""unit"":""kg""}]";
Input: 'fokhagyma, 1'
Output: [{""product"":""fokhagyma"",""quantity"":1}]
Input: 'paprika, öt rekesz'
Output: [{""product"":""paprika"",""quantity"":5}]
Input: 'mondarin öt rekesz' (typo in 'mandarin')
Output: [{""product"":""mandarin"",""quantity"":5}]
Input: 'menta, 1'
Output: [{""product"":""menta"",""quantity"":1}]
Input: 'datója tíz láda' (WRONG transcription)
Output: [{""product"":""datolya"",""quantity"":10}]
Input: 'szűlő ötven kiló' (WRONG transcription)
Output: [{""product"":""szőlő"",""quantity"":50}]";
var userPrompt = $"Parse this: {text}";
@ -417,18 +580,22 @@ Output: [{""product"":""narancs"",""quantity"":200,""unit"":""kg""},{""product""
/// <summary>
/// Enrich parsed products with actual product data from database
/// Returns ALL matching products so admin can select the exact one
/// </summary>
private async Task<List<object>> EnrichProductData(List<ParsedProduct> parsedProducts)
{
var enrichedProducts = new List<object>();
// OPTIMIZATION: Load all ProductDtos once instead of querying one by one
var helperProductDtos = await _dbContext.ProductDtos.GetAll(true).ToListAsync();
foreach (var parsed in parsedProducts)
{
// Search for matching product in database
// Search for ALL matching products in database
var products = await _productService.SearchProductsAsync(
keywords: parsed.Product,
pageIndex: 0,
pageSize: 5);
pageSize: 20); // Get up to 20 matches
if (!products.Any())
{
@ -436,37 +603,56 @@ Output: [{""product"":""narancs"",""quantity"":200,""unit"":""kg""},{""product""
continue;
}
// Take the best match (first result)
var product = products.First();
var productDto = await _dbContext.ProductDtos.GetByIdAsync(product.Id);
Console.WriteLine($"[VoiceOrder] Found {products.Count()} products matching '{parsed.Product}'");
if (productDto == null)
// Add ALL matching products for admin to choose from
foreach (var product in products)
{
Console.WriteLine($"[VoiceOrder] ProductDto not found for product ID: {product.Id}");
continue;
var productDto = helperProductDtos.FirstOrDefault(x => x.Id == product.Id);
if (productDto == null)
{
Console.WriteLine($"[VoiceOrder] ProductDto not found for product ID: {product.Id}");
continue;
}
// Check if enough stock
var availableQuantity = product.StockQuantity + productDto.IncomingQuantity;
if (availableQuantity <= 0)
{
Console.WriteLine($"[VoiceOrder] Product {product.Name} has no stock - skipping");
continue;
}
// Validate requested quantity against available stock
var requestedQuantity = parsed.Quantity;
var finalQuantity = requestedQuantity;
var isQuantityReduced = false;
if (requestedQuantity > availableQuantity)
{
Console.WriteLine($"[VoiceOrder] WARNING: Product {product.Name} - Requested {requestedQuantity} but only {availableQuantity} available. Capping to available.");
finalQuantity = availableQuantity;
isQuantityReduced = true;
}
// Add to enriched list with validated quantity
enrichedProducts.Add(new
{
id = product.Id,
name = product.Name,
sku = product.Sku,
quantity = finalQuantity, // Use validated quantity (capped to available)
requestedQuantity = requestedQuantity, // Original requested amount
price = product.Price,
stockQuantity = availableQuantity,
searchTerm = parsed.Product, // Track what was searched for
isQuantityReduced = isQuantityReduced // Flag if we had to reduce
});
}
// Check if enough stock
var availableQuantity = product.StockQuantity + productDto.IncomingQuantity;
if (availableQuantity <= 0)
{
Console.WriteLine($"[VoiceOrder] Product {product.Name} has no stock");
continue;
}
// Add to enriched list
enrichedProducts.Add(new
{
id = product.Id,
name = product.Name,
sku = product.Sku,
quantity = parsed.Quantity,
unit = parsed.Unit,
price = product.Price,
stockQuantity = availableQuantity
});
}
Console.WriteLine($"[VoiceOrder] Total enriched products to display: {enrichedProducts.Count}");
return enrichedProducts;
}
@ -476,9 +662,11 @@ Output: [{""product"":""narancs"",""quantity"":200,""unit"":""kg""},{""product""
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; }
public string Unit { get; set; }
}
#endregion

View File

@ -3,219 +3,194 @@
ViewBag.PageTitle = "Voice Order Creation";
}
<div class="content-header clearfix">
<h1 class="float-left">
<i class="fas fa-microphone"></i> Voice Order Creation
</h1>
</div>
<!-- Mobile-optimized Voice Order Creation -->
<div class="voice-order-mobile">
<section class="content">
<div class="container-fluid">
<!-- Progress Steps -->
<div class="card card-default mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div id="step1Indicator" class="alert alert-info">
<i class="fas fa-user"></i> <strong>Step 1:</strong> Select Partner
</div>
</div>
<div class="col-md-6">
<div id="step2Indicator" class="alert alert-secondary">
<i class="fas fa-box"></i> <strong>Step 2:</strong> Add Products
</div>
</div>
</div>
<!-- Fixed Header with Progress -->
<div class="mobile-header">
<div class="header-content">
<h1><i class="fas fa-microphone-alt"></i> Voice Order</h1>
<div class="progress-dots">
<div id="dot1" class="dot active"></div>
<div id="dot2" class="dot"></div>
</div>
</div>
</div>
<!-- Main Content Area (scrollable) -->
<div class="mobile-content">
<!-- Step 1: Partner Selection -->
<div id="step1Card" class="card card-primary">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-user"></i> Step 1: Select Partner
</h3>
<div id="step1" class="step-container active">
<!-- Step Title -->
<div class="step-title">
<span class="step-number">1</span>
<h2>Select Partner</h2>
</div>
<div class="card-body">
<div class="text-center mb-4">
<h4 id="step1Prompt">🎤 Tell me the partner name</h4>
</div>
<!-- Voice Recording Button -->
<div class="text-center mb-4">
<button id="recordPartnerBtn" class="btn btn-lg btn-primary">
<i class="fas fa-microphone"></i> Click to Record Partner Name
</button>
<button id="stopPartnerBtn" class="btn btn-lg btn-danger" style="display: none;">
<i class="fas fa-stop"></i> Stop Recording
<!-- Voice Button (Primary Action) -->
<div class="voice-button-container">
<button id="recordPartnerBtn" class="voice-btn">
<div class="pulse-ring"></div>
<i class="fas fa-microphone"></i>
</button>
<button id="stopPartnerBtn" class="voice-btn recording" style="display: none;">
<i class="fas fa-stop"></i>
</button>
<p class="voice-hint">Tap to speak partner name</p>
</div>
<!-- Recording Status -->
<div id="partnerRecordingStatus" class="status-indicator" style="display: none;">
<div class="spinner"></div>
<span id="partnerStatusText">Listening...</span>
</div>
<!-- OR Divider -->
<div class="divider-or">or</div>
<!-- Text Input (Secondary Option) -->
<div class="input-container">
<input type="text"
id="manualPartnerInput"
class="mobile-input"
placeholder="Type partner name..."
onkeypress="if(event.key === 'Enter') submitManualPartner()">
<button class="search-btn" onclick="submitManualPartner()">
<i class="fas fa-search"></i>
</button>
</div>
<!-- Transcription Result -->
<div id="partnerTranscribedCard" class="result-card" style="display: none;">
<div class="result-label">You said:</div>
<div id="partnerTranscribedText" class="result-text"></div>
</div>
<!-- Partner List -->
<div id="partnerMatchesCard" class="matches-container" style="display: none;">
<div class="matches-label">
<i class="fas fa-users"></i> Select partner:
</div>
<div id="partnerButtons" class="button-list"></div>
</div>
<!-- Selected Partner -->
<div id="selectedPartnerCard" class="selected-card" style="display: none;">
<div class="selected-header">
<i class="fas fa-check-circle"></i> Selected
</div>
<div class="selected-content">
<h3 id="selectedPartnerName"></h3>
<button id="changePartnerBtn" class="btn-text" onclick="resetPartnerSelection()">
<i class="fas fa-undo"></i> Change
</button>
</div>
<!-- Recording Status -->
<div id="partnerRecordingStatus" class="alert alert-info text-center" style="display: none;">
<i class="fas fa-spinner fa-spin"></i> <span id="partnerStatusText">Recording...</span>
</div>
<!-- Transcribed Text -->
<div id="partnerTranscribedCard" class="card card-secondary" style="display: none;">
<div class="card-header">
<h5 class="card-title"><i class="fas fa-comment"></i> You said:</h5>
</div>
<div class="card-body">
<p id="partnerTranscribedText" class="lead"></p>
</div>
</div>
<!-- Partner Matches -->
<div id="partnerMatchesCard" class="card card-success" style="display: none;">
<div class="card-header">
<h5 class="card-title"><i class="fas fa-users"></i> Matching Partners:</h5>
</div>
<div class="card-body">
<div id="partnerButtons" class="d-grid gap-2">
<!-- Partner buttons will be inserted here -->
</div>
</div>
</div>
<!-- Selected Partner Display -->
<div id="selectedPartnerCard" class="card card-success" style="display: none;">
<div class="card-header">
<h5 class="card-title"><i class="fas fa-check-circle"></i> Selected Partner:</h5>
</div>
<div class="card-body">
<h4 id="selectedPartnerName"></h4>
<p id="selectedPartnerDetails" class="text-muted"></p>
<button id="changePartnerBtn" class="btn btn-warning">
<i class="fas fa-undo"></i> Change Partner
</button>
<button id="proceedToProductsBtn" class="btn btn-success">
<i class="fas fa-arrow-right"></i> Proceed to Add Products
</button>
</div>
</div>
<button id="proceedToProductsBtn" class="btn-primary-mobile">
Continue to Products <i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
<!-- Step 2: Product Selection -->
<div id="step2Card" class="card card-info" style="display: none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-box"></i> Step 2: Add Products
</h3>
<div id="step2" class="step-container" style="display: none;">
<!-- Partner Summary Bar -->
<div class="partner-summary">
<i class="fas fa-user-circle"></i>
<span id="partnerSummary"></span>
</div>
<div class="card-body">
<!-- Selected Partner Summary -->
<div class="alert alert-success mb-3">
<strong><i class="fas fa-user"></i> Partner:</strong> <span id="partnerSummary"></span>
</div>
<div class="text-center mb-4">
<h4 id="step2Prompt">🎤 Tell me the products and quantities</h4>
<p class="text-muted">Example: "Narancs 100 kilogram, Alma 50 kilogram"</p>
</div>
<!-- Step Title -->
<div class="step-title">
<span class="step-number">2</span>
<h2>Add Products</h2>
</div>
<!-- Voice Recording Button -->
<div class="text-center mb-4">
<button id="recordProductBtn" class="btn btn-lg btn-primary">
<i class="fas fa-microphone"></i> Click to Record Products
</button>
<button id="stopProductBtn" class="btn btn-lg btn-danger" style="display: none;">
<i class="fas fa-stop"></i> Stop Recording
</button>
</div>
<!-- Voice Button -->
<div class="voice-button-container">
<button id="recordProductBtn" class="voice-btn">
<div class="pulse-ring"></div>
<i class="fas fa-microphone"></i>
</button>
<button id="stopProductBtn" class="voice-btn recording" style="display: none;">
<i class="fas fa-stop"></i>
</button>
<p class="voice-hint">Say: "Narancs 100, Alma 50"</p>
</div>
<!-- Recording Status -->
<div id="productRecordingStatus" class="alert alert-info text-center" style="display: none;">
<i class="fas fa-spinner fa-spin"></i> <span id="productStatusText">Recording...</span>
</div>
<!-- Recording Status -->
<div id="productRecordingStatus" class="status-indicator" style="display: none;">
<div class="spinner"></div>
<span id="productStatusText">Listening...</span>
</div>
<!-- Transcribed Text -->
<div id="productTranscribedCard" class="card card-secondary" style="display: none;">
<div class="card-header">
<h5 class="card-title"><i class="fas fa-comment"></i> You said:</h5>
</div>
<div class="card-body">
<p id="productTranscribedText" class="lead"></p>
</div>
</div>
<!-- OR Divider -->
<div class="divider-or">or</div>
<!-- Product Matches (for confirmation) -->
<div id="productMatchesCard" class="card card-warning" style="display: none;">
<div class="card-header">
<h5 class="card-title"><i class="fas fa-boxes"></i> Found Products - Click to Confirm:</h5>
</div>
<div class="card-body">
<div id="productButtons" class="d-grid gap-2">
<!-- Product buttons will be inserted here -->
</div>
</div>
</div>
<!-- Text Input -->
<div class="input-container">
<input type="text"
id="manualProductInput"
class="mobile-input"
placeholder="Type products: narancs 100, alma 50"
onkeypress="if(event.key === 'Enter') submitManualProducts()">
<button class="search-btn" onclick="submitManualProducts()">
<i class="fas fa-search"></i>
</button>
</div>
<!-- Current Order Items -->
<div id="orderItemsCard" class="card card-success" style="display: none;">
<div class="card-header">
<h5 class="card-title"><i class="fas fa-shopping-cart"></i> Current Order Items:</h5>
</div>
<div class="card-body">
<table class="table table-bordered" id="orderItemsTable">
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Total</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="orderItemsBody">
<!-- Order items will be inserted here -->
</tbody>
<tfoot>
<tr>
<th colspan="3" class="text-right">Order Total:</th>
<th id="orderTotalDisplay">0.00 Ft</th>
<th></th>
</tr>
</tfoot>
</table>
<!-- Transcription Result -->
<div id="productTranscribedCard" class="result-card" style="display: none;">
<div class="result-label">You said:</div>
<div id="productTranscribedText" class="result-text"></div>
</div>
<div class="text-center mt-3">
<button id="addMoreProductsBtn" class="btn btn-primary">
<i class="fas fa-microphone"></i> Add More Products
</button>
<button id="finishOrderBtn" class="btn btn-success btn-lg">
<i class="fas fa-check"></i> Finish & Create Order
</button>
</div>
</div>
<!-- Product Matches -->
<div id="productMatchesCard" class="matches-container" style="display: none;">
<div class="matches-label">
<i class="fas fa-boxes"></i> Tap to add:
</div>
<div id="productButtons" class="button-list"></div>
</div>
<!-- Order Items -->
<div id="orderItemsCard" class="order-summary" style="display: none;">
<div class="order-header">
<h3><i class="fas fa-shopping-cart"></i> Order Items</h3>
</div>
<div class="order-items" id="orderItemsList"></div>
<div class="order-total">
<span>Total:</span>
<strong id="orderTotalDisplay">0.00 Ft</strong>
</div>
<button id="addMoreBtn" class="btn-secondary-mobile" onclick="addMoreProducts()">
<i class="fas fa-plus"></i> Add More Products
</button>
<button id="finishOrderBtn" class="btn-primary-mobile" onclick="finishOrder()">
<i class="fas fa-check"></i> Create Order
</button>
</div>
</div>
<!-- Order Creation Success -->
<div id="successCard" class="card card-success" style="display: none;">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-check-circle"></i> Order Created Successfully!
</h3>
</div>
<div class="card-body text-center">
<h3>Order #<span id="createdOrderId"></span> has been created!</h3>
<div class="mt-4">
<a id="viewOrderBtn" href="#" class="btn btn-primary btn-lg">
<i class="fas fa-eye"></i> View Order
</a>
<button id="createAnotherOrderBtn" class="btn btn-secondary btn-lg">
<i class="fas fa-plus"></i> Create Another Order
</button>
</div>
<!-- Success Screen -->
<div id="successCard" class="step-container success-screen" style="display: none;">
<div class="success-icon">
<i class="fas fa-check-circle"></i>
</div>
<h2>Order Created!</h2>
<p class="success-order-id">Order #<span id="createdOrderId"></span></p>
<button id="viewOrderBtn" class="btn-primary-mobile">
<i class="fas fa-eye"></i> View Order
</button>
<button class="btn-text" onclick="resetWizard()">
<i class="fas fa-plus"></i> Create Another Order
</button>
</div>
</div>
</section>
</div>
<script>
// State management
@ -223,99 +198,337 @@
let selectedPartnerId = null;
let selectedPartnerName = "";
let orderItems = [];
let orderTotal = 0;
let mediaRecorder = null;
let audioChunks = [];
let orderTotal = 0;
$(document).ready(function() {
setupEventHandlers();
});
function setupEventHandlers() {
// Partner recording
// Event listeners
$('#recordPartnerBtn').click(() => startRecording('partner'));
$('#stopPartnerBtn').click(() => stopRecording('partner'));
// Product recording
$('#recordProductBtn').click(() => startRecording('product'));
$('#stopProductBtn').click(() => stopRecording('product'));
// Navigation
$('#changePartnerBtn').click(resetPartnerSelection);
$('#proceedToProductsBtn').click(proceedToProducts);
$('#addMoreProductsBtn').click(addMoreProducts);
$('#finishOrderBtn').click(finishOrder);
$('#createAnotherOrderBtn').click(resetWizard);
$('#proceedToProductsBtn').click(proceedToStep2);
// Check microphone availability and permissions on load
checkMicrophoneAvailability();
});
async function checkMicrophoneAvailability() {
console.log('[VoiceOrder] Checking microphone availability...');
console.log('[VoiceOrder] Protocol:', window.location.protocol);
console.log('[VoiceOrder] Hostname:', window.location.hostname);
console.log('[VoiceOrder] Is secure context:', window.isSecureContext);
// Check if getUserMedia is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('[VoiceOrder] getUserMedia not supported');
showWarningBanner('Your browser does not support audio recording. Please use Chrome, Firefox, or Safari.');
return;
}
// Check if HTTPS (except localhost)
if (window.location.protocol !== 'https:' &&
window.location.hostname !== 'localhost' &&
window.location.hostname !== '127.0.0.1') {
console.error('[VoiceOrder] Not HTTPS');
showWarningBanner('Voice recording requires HTTPS. Please use a secure connection.');
return;
}
// Try to enumerate devices (doesn't require permission)
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(device => device.kind === 'audioinput');
console.log('[VoiceOrder] Audio input devices found:', audioInputs.length);
if (audioInputs.length === 0) {
showWarningBanner('No microphone detected. Please connect a microphone to use voice recording.');
}
} catch (error) {
console.error('[VoiceOrder] Error enumerating devices:', error);
}
// Check permissions API if available
if (navigator.permissions && navigator.permissions.query) {
try {
const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
console.log('[VoiceOrder] Microphone permission status:', permissionStatus.state);
if (permissionStatus.state === 'denied') {
showWarningBanner('Microphone access was denied. Please enable it in your browser settings.');
}
// Listen for permission changes
permissionStatus.onchange = function() {
console.log('[VoiceOrder] Permission changed to:', this.state);
};
} catch (error) {
// Permissions API not supported or microphone permission not available
console.log('[VoiceOrder] Permissions API not available:', error.message);
}
}
}
function showWarningBanner(message) {
const banner = $(`
<div class="permission-warning">
<i class="fas fa-exclamation-triangle"></i>
<span>${message}</span>
</div>
`);
$('.mobile-content').prepend(banner);
}
async function startRecording(type) {
console.log('[VoiceOrder] ========== START RECORDING ATTEMPT ==========');
console.log('[VoiceOrder] Type:', type);
console.log('[VoiceOrder] Timestamp:', new Date().toISOString());
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
// Check 1: Browser support
console.log('[VoiceOrder] Check 1: Browser support');
if (!navigator.mediaDevices) {
console.error('[VoiceOrder] FAIL: navigator.mediaDevices is undefined');
alert('Your browser does not support mediaDevices API. Browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: navigator.mediaDevices exists');
if (!navigator.mediaDevices.getUserMedia) {
console.error('[VoiceOrder] FAIL: getUserMedia is undefined');
alert('Your browser does not support getUserMedia. Browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: getUserMedia exists');
// Check 2: Security context
console.log('[VoiceOrder] Check 2: Security context');
console.log('[VoiceOrder] - Protocol:', window.location.protocol);
console.log('[VoiceOrder] - Hostname:', window.location.hostname);
console.log('[VoiceOrder] - IsSecureContext:', window.isSecureContext);
console.log('[VoiceOrder] - Full URL:', window.location.href);
// Check 3: Try simple audio constraint first
console.log('[VoiceOrder] Check 3: Requesting microphone with simple constraints');
console.log('[VoiceOrder] Calling getUserMedia({ audio: true })...');
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('[VoiceOrder] SUCCESS: Got media stream');
console.log('[VoiceOrder] Stream ID:', stream.id);
console.log('[VoiceOrder] Stream active:', stream.active);
console.log('[VoiceOrder] Audio tracks:', stream.getAudioTracks().length);
if (stream.getAudioTracks().length > 0) {
const track = stream.getAudioTracks()[0];
console.log('[VoiceOrder] Track label:', track.label);
console.log('[VoiceOrder] Track enabled:', track.enabled);
console.log('[VoiceOrder] Track muted:', track.muted);
console.log('[VoiceOrder] Track readyState:', track.readyState);
console.log('[VoiceOrder] Track settings:', track.getSettings());
}
} catch (getUserMediaError) {
console.error('[VoiceOrder] FAIL: getUserMedia threw error');
console.error('[VoiceOrder] Error name:', getUserMediaError.name);
console.error('[VoiceOrder] Error message:', getUserMediaError.message);
console.error('[VoiceOrder] Error stack:', getUserMediaError.stack);
throw getUserMediaError;
}
// Check 4: MediaRecorder support
console.log('[VoiceOrder] Check 4: MediaRecorder support');
if (!window.MediaRecorder) {
console.error('[VoiceOrder] FAIL: MediaRecorder not supported');
stream.getTracks().forEach(track => track.stop());
alert('MediaRecorder is not supported in your browser: ' + navigator.userAgent);
return;
}
console.log('[VoiceOrder] PASS: MediaRecorder exists');
// Check 5: Check supported MIME types
console.log('[VoiceOrder] Check 5: Checking MIME type support');
const mimeTypes = [
'audio/webm',
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4',
'audio/mpeg'
];
let supportedMimeType = null;
for (const mimeType of mimeTypes) {
const isSupported = MediaRecorder.isTypeSupported(mimeType);
console.log('[VoiceOrder] - ' + mimeType + ':', isSupported);
if (isSupported && !supportedMimeType) {
supportedMimeType = mimeType;
}
}
if (!supportedMimeType) {
console.error('[VoiceOrder] FAIL: No supported MIME type found');
stream.getTracks().forEach(track => track.stop());
alert('No supported audio recording format found in your browser');
return;
}
console.log('[VoiceOrder] Using MIME type:', supportedMimeType);
// Check 6: Create MediaRecorder
console.log('[VoiceOrder] Check 6: Creating MediaRecorder');
try {
mediaRecorder = new MediaRecorder(stream, {
mimeType: supportedMimeType
});
console.log('[VoiceOrder] SUCCESS: MediaRecorder created');
console.log('[VoiceOrder] MediaRecorder state:', mediaRecorder.state);
} catch (recorderError) {
console.error('[VoiceOrder] FAIL: MediaRecorder constructor threw error');
console.error('[VoiceOrder] Error:', recorderError);
stream.getTracks().forEach(track => track.stop());
throw recorderError;
}
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
mediaRecorder.addEventListener('dataavailable', event => {
console.log('[VoiceOrder] dataavailable event, size:', event.data.size);
audioChunks.push(event.data);
};
});
mediaRecorder.addEventListener('stop', () => {
console.log('[VoiceOrder] MediaRecorder stopped');
console.log('[VoiceOrder] Audio chunks:', audioChunks.length);
const audioBlob = new Blob(audioChunks, { type: supportedMimeType });
console.log('[VoiceOrder] Blob created, size:', audioBlob.size);
console.log('[VoiceOrder] Blob type:', audioBlob.type);
if (audioBlob.size === 0) {
console.error('[VoiceOrder] WARNING: Blob size is 0!');
alert('Recording failed: No audio data captured. Please check your microphone.');
resetRecordingUI(type);
return;
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
processAudio(audioBlob, type);
stream.getTracks().forEach(track => track.stop());
};
stream.getTracks().forEach(track => {
console.log('[VoiceOrder] Stopping track:', track.label);
track.stop();
});
});
mediaRecorder.start();
mediaRecorder.addEventListener('error', event => {
console.error('[VoiceOrder] MediaRecorder error event:', event);
console.error('[VoiceOrder] Error:', event.error);
});
// Check 7: Start recording
console.log('[VoiceOrder] Check 7: Starting recording');
try {
mediaRecorder.start();
console.log('[VoiceOrder] SUCCESS: Recording started');
console.log('[VoiceOrder] MediaRecorder state after start:', mediaRecorder.state);
} catch (startError) {
console.error('[VoiceOrder] FAIL: start() threw error');
console.error('[VoiceOrder] Error:', startError);
stream.getTracks().forEach(track => track.stop());
throw startError;
}
// Update UI
if (type === 'partner') {
$('#recordPartnerBtn').hide();
$('#stopPartnerBtn').show();
showStatus('partnerRecordingStatus', 'Recording... Speak now!');
showStatus('partnerRecordingStatus', 'Listening...');
} else {
$('#recordProductBtn').hide();
$('#stopProductBtn').show();
showStatus('productRecordingStatus', 'Recording... Speak now!');
showStatus('productRecordingStatus', 'Listening...');
}
console.log('[VoiceOrder] ========== RECORDING STARTED SUCCESSFULLY ==========');
} catch (error) {
console.error('Error accessing microphone:', error);
alert('Could not access microphone. Please check permissions.');
console.error('[VoiceOrder] ========== RECORDING FAILED ==========');
console.error('[VoiceOrder] Error name:', error.name);
console.error('[VoiceOrder] Error message:', error.message);
console.error('[VoiceOrder] Error stack:', error.stack);
console.error('[VoiceOrder] Full error object:', error);
let errorMessage = 'Recording failed: ';
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
errorMessage += 'Microphone permission was denied.\n\n';
errorMessage += 'Steps to fix:\n';
errorMessage += '1. Click the 🔒 or ⓘ icon in the address bar\n';
errorMessage += '2. Find "Microphone" in permissions\n';
errorMessage += '3. Change to "Allow"\n';
errorMessage += '4. Refresh the page\n\n';
errorMessage += 'Current permission status: Check browser console for details';
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMessage += 'No microphone device found.\n\n';
errorMessage += 'Please check:\n';
errorMessage += '- Is a microphone connected?\n';
errorMessage += '- Are you using headphones with a mic?\n';
errorMessage += '- Try a different microphone';
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMessage += 'Microphone is in use by another application.\n\n';
errorMessage += 'Please:\n';
errorMessage += '- Close other apps using the microphone\n';
errorMessage += '- Close other browser tabs using microphone\n';
errorMessage += '- Restart your browser';
} else if (error.name === 'OverconstrainedError') {
errorMessage += 'Audio recording constraints not supported.\n\n';
errorMessage += 'Try: Refresh the page and try again';
} else if (error.name === 'SecurityError') {
errorMessage += 'Security error accessing microphone.\n\n';
errorMessage += 'Check:\n';
errorMessage += '- Page must use HTTPS (currently: ' + window.location.protocol + ')\n';
errorMessage += '- Valid SSL certificate\n';
errorMessage += '- No mixed content (HTTP resources on HTTPS page)';
} else if (error.name === 'TypeError') {
errorMessage += 'Browser compatibility issue.\n\n';
errorMessage += 'Your browser: ' + navigator.userAgent + '\n\n';
errorMessage += 'Try: Update your browser to the latest version';
} else {
errorMessage += error.message || 'Unknown error\n\n';
errorMessage += 'Browser: ' + navigator.userAgent + '\n';
errorMessage += 'Error type: ' + error.name;
}
errorMessage += '\n\n** Check browser console (F12) for detailed technical information **';
alert(errorMessage);
resetRecordingUI(type);
}
}
function stopRecording(type) {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
// Update UI
if (type === 'partner') {
$('#stopPartnerBtn').hide();
showStatus('partnerRecordingStatus', 'Processing audio...');
} else {
$('#stopProductBtn').hide();
showStatus('productRecordingStatus', 'Processing audio...');
}
showStatus(type === 'partner' ? 'partnerRecordingStatus' : 'productRecordingStatus', 'Processing...');
}
}
async function processAudio(audioBlob, type) {
const formData = new FormData();
formData.append('audioFile', audioBlob, 'recording.webm');
const endpoint = type === 'partner'
? '@Url.Action("TranscribeForPartner", "VoiceOrder")'
: '@Url.Action("TranscribeForProducts", "VoiceOrder")';
formData.append('__RequestVerificationToken', $('input[name="__RequestVerificationToken"]').val());
try {
const endpoint = type === 'partner'
? '@Url.Action("TranscribeForPartner", "VoiceOrder")'
: '@Url.Action("TranscribeForProducts", "VoiceOrder")';
const response = await fetch(endpoint, {
method: 'POST',
body: formData,
headers: {
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
}
body: formData
});
const result = await response.json();
resetRecordingUI(type);
if (result.success) {
if (type === 'partner') {
handlePartnerTranscription(result);
@ -324,45 +537,43 @@
}
} else {
alert('Error: ' + result.message);
resetRecordingUI(type);
}
} catch (error) {
resetRecordingUI(type);
console.error('Error processing audio:', error);
alert('Error processing audio: ' + error.message);
resetRecordingUI(type);
}
}
function handlePartnerTranscription(result) {
$('#partnerRecordingStatus').hide();
$('#recordPartnerBtn').show();
// Show transcribed text
$('#partnerTranscribedText').text(result.transcription);
$('#partnerTranscribedCard').show();
// Show matching partners
if (result.partners && result.partners.length > 0) {
displayPartnerMatches(result.partners);
} else {
alert('No matching partners found. Please try again.');
}
displayPartnerMatches(result.partners);
}
function displayPartnerMatches(partners) {
const container = $('#partnerButtons');
container.empty();
if (partners.length === 0) {
container.append('<p class="no-results">No partners found</p>');
$('#partnerMatchesCard').show();
return;
}
partners.forEach(partner => {
const btn = $('<button>')
.addClass('btn btn-outline-primary btn-lg mb-2 text-left')
.css('width', '100%')
.addClass('list-item-btn')
.html(`
<i class="fas fa-user"></i> <strong>${partner.label}</strong><br>
<small class="text-muted">ID: ${partner.value}</small>
<div class="item-content">
<i class="fas fa-building"></i>
<span>${partner.label}</span>
</div>
<i class="fas fa-chevron-right"></i>
`)
.click(() => selectPartner(partner));
container.append(btn);
});
@ -374,8 +585,6 @@
selectedPartnerName = partner.label;
$('#selectedPartnerName').text(partner.label);
$('#selectedPartnerDetails').text('Customer ID: ' + partner.value);
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').show();
}
@ -383,117 +592,208 @@
function resetPartnerSelection() {
selectedPartnerId = null;
selectedPartnerName = "";
$('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide();
$('#recordPartnerBtn').show();
$('#manualPartnerInput').val('');
}
function proceedToProducts() {
async function submitManualPartner() {
const text = $('#manualPartnerInput').val().trim();
if (!text) {
alert('Please enter a partner name');
return;
}
showStatus('partnerRecordingStatus', 'Searching...');
try {
const response = await fetch('@Url.Action("SearchPartnerByText", "VoiceOrder")', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: new URLSearchParams({
text: text,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
})
});
const result = await response.json();
$('#partnerRecordingStatus').hide();
if (result.success) {
handlePartnerTranscription(result);
} else {
alert('Error: ' + result.message);
}
} catch (error) {
$('#partnerRecordingStatus').hide();
console.error('Error searching partners:', error);
alert('Error: ' + error.message);
}
}
function proceedToStep2() {
currentStep = 2;
// Update step indicators
$('#step1Indicator').removeClass('alert-info').addClass('alert-success');
$('#step2Indicator').removeClass('alert-secondary').addClass('alert-info');
// Hide step 1, show step 2
$('#step1Card').hide();
$('#step2Card').show();
// Update partner summary
// Update progress dots
$('#dot1').removeClass('active').addClass('completed');
$('#dot2').addClass('active');
// Update UI
$('#step1').removeClass('active').hide();
$('#step2').addClass('active').show();
$('#partnerSummary').text(selectedPartnerName);
// Scroll to top
$('.mobile-content').scrollTop(0);
}
function handleProductTranscription(result) {
$('#productRecordingStatus').hide();
$('#recordProductBtn').show();
// Show transcribed text
$('#productTranscribedText').text(result.transcription);
$('#productTranscribedCard').show();
// Show matching products for confirmation
if (result.products && result.products.length > 0) {
displayProductMatches(result.products);
} else {
alert('No matching products found. Please try again.');
}
displayProductMatches(result.products);
}
function displayProductMatches(products) {
const container = $('#productButtons');
container.empty();
products.forEach(product => {
const btn = $('<button>')
.addClass('btn btn-outline-success btn-lg mb-2 text-left')
.css('width', '100%')
.html(`
<i class="fas fa-box"></i> <strong>${product.name}</strong><br>
<small>Quantity: ${product.quantity} ${product.unit || 'kg'} | Price: ${product.price.toFixed(2)} Ft | Available: ${product.stockQuantity}</small>
`)
.click(() => addProductToOrder(product));
container.append(btn);
if (products.length === 0) {
container.append('<p class="no-results">No products found</p>');
$('#productMatchesCard').show();
return;
}
// Group by search term
const grouped = {};
products.forEach(p => {
const term = p.searchTerm || 'other';
if (!grouped[term]) grouped[term] = [];
grouped[term].push(p);
});
Object.keys(grouped).forEach(term => {
if (Object.keys(grouped).length > 1) {
container.append(`<div class="group-label">${term}</div>`);
}
grouped[term].forEach(product => {
const hasStockWarning = product.isQuantityReduced;
const warningBadge = hasStockWarning
? `<span class="stock-warning-badge">⚠️ Only ${product.quantity} available (you said ${product.requestedQuantity})</span>`
: '';
const productCard = $('<div>')
.addClass('product-card-wrapper')
.addClass(hasStockWarning ? 'has-warning' : '');
const productButton = $('<button>')
.addClass('product-item-btn')
.addClass(hasStockWarning ? 'stock-warning' : '')
.attr('type', 'button')
.html(`
<div class="product-info">
<div class="product-name">
<i class="fas fa-box"></i>
<strong>${product.name}</strong>
</div>
${warningBadge}
<div class="product-details">
<span class="qty-badge ${hasStockWarning ? 'qty-reduced' : ''}">${product.quantity}</span>
<span class="stock ${product.stockQuantity < 50 ? 'stock-low' : ''}">Stock: ${product.stockQuantity}</span>
</div>
</div>
<i class="fas fa-plus-circle"></i>
`);
const priceEditor = $('<div>')
.addClass('price-editor')
.html(`
<label class="price-label">
<i class="fas fa-tag"></i> Price:
</label>
<input type="number"
class="price-input"
value="${product.price.toFixed(0)}"
min="0"
step="1"
data-product-id="${product.id}"
onclick="event.stopPropagation()">
<span class="price-unit">Ft</span>
`);
productButton.click(() => {
const customPrice = parseFloat(priceEditor.find('.price-input').val()) || product.price;
addProductToOrder({...product, price: customPrice});
});
productCard.append(productButton);
productCard.append(priceEditor);
container.append(productCard);
});
});
$('#productMatchesCard').show();
}
function addProductToOrder(product) {
// Check if product already exists in order
const existingIndex = orderItems.findIndex(item => item.id === product.id);
if (existingIndex >= 0) {
// Update quantity
orderItems[existingIndex].quantity += product.quantity;
} else {
// Add new item
orderItems.push({
id: product.id,
name: product.name,
sku: product.sku,
quantity: product.quantity,
price: product.price,
stockQuantity: product.stockQuantity
});
}
orderItems.push({
id: product.id,
name: product.name,
quantity: product.quantity,
price: product.price
});
updateOrderItemsDisplay();
// Hide product matches after adding
$('#productMatchesCard').hide();
$('#productTranscribedCard').hide();
$('#recordProductBtn').show();
$('#manualProductInput').val('');
}
function updateOrderItemsDisplay() {
const tbody = $('#orderItemsBody');
tbody.empty();
const container = $('#orderItemsList');
container.empty();
orderTotal = 0;
orderItems.forEach((item, index) => {
const itemTotal = item.quantity * item.price;
orderTotal += itemTotal;
const row = $('<tr>').html(`
<td>${item.name}<br><small class="text-muted">SKU: ${item.sku}</small></td>
<td>
<input type="number" class="form-control" value="${item.quantity}"
onchange="updateQuantity(${index}, this.value)" min="1" max="${item.stockQuantity}">
</td>
<td>${item.price.toFixed(2)} Ft</td>
<td>${itemTotal.toFixed(2)} Ft</td>
<td>
<button class="btn btn-sm btn-danger" onclick="removeItem(${index})">
<i class="fas fa-trash"></i>
</button>
</td>
const itemEl = $(`
<div class="order-item">
<div class="order-item-header">
<span class="item-name">${item.name}</span>
<button class="btn-icon-delete" onclick="removeItem(${index})">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="order-item-details">
<input type="number"
class="qty-input"
value="${item.quantity}"
min="1"
onchange="updateQuantity(${index}, this.value)">
<span class="item-price">${item.price.toFixed(0)} Ft/unit</span>
<strong class="item-total">${itemTotal.toFixed(0)} Ft</strong>
</div>
</div>
`);
tbody.append(row);
container.append(itemEl);
});
$('#orderTotalDisplay').text(orderTotal.toFixed(2) + ' Ft');
$('#orderTotalDisplay').text(orderTotal.toFixed(0) + ' Ft');
$('#orderItemsCard').show();
}
@ -505,7 +805,7 @@
window.removeItem = function(index) {
orderItems.splice(index, 1);
updateOrderItemsDisplay();
if (orderItems.length === 0) {
$('#orderItemsCard').hide();
}
@ -515,24 +815,58 @@
$('#productTranscribedCard').hide();
$('#productMatchesCard').hide();
$('#recordProductBtn').show();
$('#manualProductInput').val('');
}
async function submitManualProducts() {
const text = $('#manualProductInput').val().trim();
if (!text) {
alert('Please enter some products');
return;
}
showStatus('productRecordingStatus', 'Processing...');
try {
const response = await fetch('@Url.Action("ParseManualProductText", "VoiceOrder")', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
},
body: new URLSearchParams({
text: text,
__RequestVerificationToken: $('input[name="__RequestVerificationToken"]').val()
})
});
const result = await response.json();
$('#productRecordingStatus').hide();
if (result.success) {
handleProductTranscription(result);
} else {
alert('Error: ' + result.message);
}
} catch (error) {
$('#productRecordingStatus').hide();
console.error('Error:', error);
alert('Error: ' + error.message);
}
}
async function finishOrder() {
if (!selectedPartnerId) {
alert('No partner selected!');
if (!selectedPartnerId || orderItems.length === 0) {
alert('Please select a partner and add products');
return;
}
if (orderItems.length === 0) {
alert('No products in order!');
return;
}
$('#finishOrderBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating order...');
$('#finishOrderBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Creating...');
try {
const orderProductsJson = JSON.stringify(orderItems);
const response = await fetch('@Url.Action("Create", "CustomOrder")', {
method: 'POST',
headers: {
@ -546,60 +880,60 @@
})
});
// Check if redirect happened (order created successfully)
if (response.redirected) {
// Extract order ID from redirect URL
const url = new URL(response.url);
const orderId = url.searchParams.get('id');
if (orderId) {
showSuccess(orderId);
} else {
// Fallback: redirect to the order edit page
window.location.href = response.url;
}
} else {
alert('Error creating order. Please try again.');
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Finish & Create Order');
alert('Error creating order');
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Create Order');
}
} catch (error) {
console.error('Error creating order:', error);
alert('Error creating order: ' + error.message);
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Finish & Create Order');
console.error('Error:', error);
alert('Error: ' + error.message);
$('#finishOrderBtn').prop('disabled', false).html('<i class="fas fa-check"></i> Create Order');
}
}
function showSuccess(orderId) {
$('#step2Card').hide();
$('#step2').hide();
$('#successCard').show();
$('#createdOrderId').text(orderId);
$('#viewOrderBtn').attr('href', '@Url.Action("Edit", "Order")?id=' + orderId);
$('.mobile-content').scrollTop(0);
}
function resetWizard() {
// Reset all state
currentStep = 1;
selectedPartnerId = null;
selectedPartnerName = "";
orderItems = [];
orderTotal = 0;
// Reset UI
$('#step1Indicator').removeClass('alert-success').addClass('alert-info');
$('#step2Indicator').removeClass('alert-info').addClass('alert-secondary');
$('#dot1').removeClass('completed').addClass('active');
$('#dot2').removeClass('active completed');
$('#successCard').hide();
$('#step2Card').hide();
$('#step1Card').show();
$('#step2').hide();
$('#step1').show().addClass('active');
$('#partnerTranscribedCard').hide();
$('#partnerMatchesCard').hide();
$('#selectedPartnerCard').hide();
$('#productTranscribedCard').hide();
$('#productMatchesCard').hide();
$('#orderItemsCard').hide();
$('#recordPartnerBtn').show();
$('#manualPartnerInput').val('');
$('#manualProductInput').val('');
$('.mobile-content').scrollTop(0);
}
function showStatus(elementId, message) {
@ -621,29 +955,817 @@
</script>
<style>
.d-grid {
display: grid;
/* Mobile-First Reset */
.voice-order-mobile {
/* position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0; */
background: #f5f7fa;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.gap-2 {
/* Fixed Header */
.mobile-header {
/* position: fixed;
top: 0;
left: 0;
right: 0; */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 100;
}
.header-content h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
}
/* Progress Dots */
.progress-dots {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.3);
transition: all 0.3s;
}
.dot.active {
width: 24px;
border-radius: 4px;
background: white;
}
.dot.completed {
background: #4ade80;
}
/* Scrollable Content */
.mobile-content {
/* position: absolute;
top: 90px;
left: 0;
right: 0;
bottom: 0; */
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
}
/* Step Container */
.step-container {
display: none;
animation: slideIn 0.3s ease-out;
}
.step-container.active {
display: block;
}
@@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Step Title */
.step-title {
text-align: center;
margin-bottom: 2rem;
}
.step-number {
display: inline-block;
width: 32px;
height: 32px;
line-height: 32px;
background: #667eea;
color: white;
border-radius: 50%;
font-weight: 600;
margin-bottom: 0.5rem;
}
.step-title h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0.5rem 0 0 0;
}
/* Voice Button (Hero Element) */
.voice-button-container {
text-align: center;
margin: 2rem 0;
}
.voice-btn {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 2.5rem;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
cursor: pointer;
transition: all 0.3s;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.voice-btn:active {
transform: scale(0.95);
}
.voice-btn.recording {
background: #ef4444;
animation: pulse 1.5s infinite;
}
@@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
50% {
box-shadow: 0 0 0 20px rgba(239, 68, 68, 0);
}
}
.pulse-ring {
position: absolute;
width: 140px;
height: 140px;
border-radius: 50%;
border: 3px solid #667eea;
opacity: 0;
animation: pulseRing 2s infinite;
}
@@keyframes pulseRing {
0% {
transform: scale(0.9);
opacity: 0.5;
}
100% {
transform: scale(1.3);
opacity: 0;
}
}
.voice-hint {
margin-top: 1rem;
color: #6b7280;
font-size: 0.9rem;
}
/* Status Indicator */
.status-indicator {
text-align: center;
padding: 1rem;
background: white;
border-radius: 12px;
margin: 1rem 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
vertical-align: middle;
}
@@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* OR Divider */
.divider-or {
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
margin: 1.5rem 0;
position: relative;
}
.divider-or:before,
.divider-or:after {
content: '';
position: absolute;
top: 50%;
width: 40%;
height: 1px;
background: #e5e7eb;
}
.divider-or:before {
left: 0;
}
.divider-or:after {
right: 0;
}
/* Input Container */
.input-container {
display: flex;
gap: 0.5rem;
margin: 1rem 0 2rem 0;
}
.mobile-input {
flex: 1;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
transition: border-color 0.2s;
}
.mobile-input:focus {
outline: none;
border-color: #667eea;
}
.search-btn {
width: 50px;
height: 50px;
border: none;
border-radius: 12px;
background: #667eea;
color: white;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s;
}
.search-btn:active {
transform: scale(0.95);
}
/* Result Card */
.result-card {
background: white;
border-radius: 12px;
padding: 1rem;
margin: 1rem 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.result-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 0.5rem;
}
.result-text {
font-size: 1.125rem;
color: #1f2937;
}
/* Matches Container */
.matches-container {
margin: 1.5rem 0;
}
.matches-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
#step1Indicator, #step2Indicator {
font-size: 1.1rem;
text-align: center;
margin-bottom: 0;
.button-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.btn-lg {
padding: 1rem 2rem;
/* Product Card Wrapper */
.product-card-wrapper {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.product-card-wrapper.has-warning {
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
}
/* List Item Button */
.list-item-btn {
width: 100%;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
background: white;
text-align: left;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.list-item-btn:active {
transform: scale(0.98);
border-color: #667eea;
}
.item-content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.item-content i {
color: #667eea;
font-size: 1.25rem;
}
.item-content span {
font-size: 0.95rem;
color: #1f2937;
}
/* Product Item Button */
.product-item-btn {
width: 100%;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px 12px 0 0;
background: white;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: none;
}
.product-item-btn.stock-warning {
border-color: #f59e0b;
background: #fffbeb;
}
.product-item-btn:active {
transform: scale(0.98);
}
.product-card-wrapper:hover .product-item-btn {
background: #f9fafb;
}
.product-card-wrapper.has-warning:hover .product-item-btn {
background: #fef3c7;
}
/* Price Editor */
.price-editor {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 2px solid #e5e7eb;
border-top: 1px solid #e5e7eb;
border-radius: 0 0 12px 12px;
}
.product-card-wrapper.has-warning .price-editor {
background: #fffbeb;
border-color: #f59e0b;
}
.price-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0;
}
.price-label i {
color: #667eea;
}
.price-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
color: #1f2937;
background: white;
text-align: right;
max-width: 120px;
transition: border-color 0.2s;
}
.price-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.price-input::-webkit-inner-spin-button,
.price-input::-webkit-outer-spin-button {
opacity: 1;
}
.price-unit {
font-size: 0.875rem;
color: #6b7280;
font-weight: 600;
}
.stock-warning-badge {
display: block;
background: #fef3c7;
color: #92400e;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
margin: 0.5rem 0;
border: 1px solid #f59e0b;
}
.product-info {
flex: 1;
text-align: left;
}
.product-name {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.product-name i {
color: #667eea;
}
.product-name strong {
font-size: 1rem;
color: #1f2937;
}
.product-details {
display: flex;
gap: 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.qty-badge {
background: #667eea;
color: white;
padding: 0.125rem 0.5rem;
border-radius: 6px;
font-weight: 600;
}
.qty-badge.qty-reduced {
background: #f59e0b;
animation: pulse-warning 2s infinite;
}
@@keyframes pulse-warning {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.price {
font-weight: 600;
color: #1f2937;
}
.stock {
color: #10b981;
}
.stock.stock-low {
color: #f59e0b;
font-weight: 600;
}
.group-label {
font-size: 0.75rem;
color: #667eea;
font-weight: 600;
text-transform: uppercase;
margin: 1rem 0 0.5rem 0;
}
/* Selected Card */
.selected-card {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border-radius: 16px;
padding: 1.5rem;
margin: 1.5rem 0;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.selected-header {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 0.5rem;
opacity: 0.9;
}
.selected-content h3 {
font-size: 1.25rem;
margin: 0 0 1rem 0;
}
.card-body h4 {
.btn-text {
background: transparent;
border: none;
color: white;
font-size: 0.875rem;
cursor: pointer;
margin-bottom: 1rem;
text-decoration: underline;
}
/* Primary Button */
.btn-primary-mobile {
width: 100%;
padding: 1rem;
border: none;
border-radius: 12px;
background: #667eea;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
margin-top: 1rem;
}
.btn-primary-mobile:active {
transform: scale(0.98);
}
.btn-primary-mobile:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Secondary Button */
.btn-secondary-mobile {
width: 100%;
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 12px;
background: white;
color: #667eea;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 1rem;
}
.btn-secondary-mobile:active {
transform: scale(0.98);
}
/* Partner Summary Bar */
.partner-summary {
background: white;
padding: 0.75rem 1rem;
border-radius: 12px;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: #1f2937;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.partner-summary i {
color: #667eea;
}
/* Order Summary */
.order-summary {
background: white;
border-radius: 16px;
padding: 1.5rem;
margin: 2rem 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.order-header {
margin-bottom: 1rem;
}
.order-header h3 {
font-size: 1.25rem;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.order-items {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.order-item {
background: #f9fafb;
border-radius: 12px;
padding: 1rem;
}
.order-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.item-name {
font-weight: 600;
color: #1f2937;
}
.btn-icon-delete {
background: transparent;
border: none;
color: #ef4444;
cursor: pointer;
padding: 0.25rem;
}
.order-item-details {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.qty-input {
width: 60px;
padding: 0.5rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
text-align: center;
font-weight: 600;
}
.item-price {
color: #6b7280;
}
.item-total {
margin-left: auto;
color: #1f2937;
font-size: 1rem;
}
.order-total {
display: flex;
justify-content: space-between;
padding: 1rem 0;
border-top: 2px solid #e5e7eb;
font-size: 1.25rem;
font-weight: 600;
}
/* Success Screen */
.success-screen {
text-align: center;
padding: 2rem 0;
}
.success-icon {
font-size: 5rem;
color: #10b981;
margin: 2rem 0;
animation: scaleIn 0.5s ease-out;
}
@@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.success-screen h2 {
font-size: 2rem;
margin: 1rem 0;
color: #1f2937;
}
.success-order-id {
font-size: 1.125rem;
color: #6b7280;
margin-bottom: 2rem;
}
.no-results {
text-align: center;
color: #6b7280;
padding: 2rem;
}
/* Permission Warning Banner */
.permission-warning {
background: #fef3c7;
border: 2px solid #f59e0b;
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
color: #92400e;
font-size: 0.9rem;
}
.permission-warning i {
font-size: 1.5rem;
color: #f59e0b;
flex-shrink: 0;
}
/* Responsive adjustments */
@@media (max-width: 375px) {
.voice-btn {
width: 100px;
height: 100px;
font-size: 2rem;
}
}
/* Prevent zoom on input focus (iOS) */
@@media screen and (max-width: 768px) {
input, select, textarea {
font-size: 16px;
}
}
</style>
@* Anti-forgery token *@
@Html.AntiForgeryToken()

View File

@ -365,6 +365,13 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Controllers
_logger.Detail($"AddShippingDocument invoked; id: {shippingDocument.Id}");
await ctx.ShippingDocuments.InsertAsync(shippingDocument);
foreach (var item in shippingDocument.ShippingItems)
{
var product = await ctx.Products.GetByIdAsync(item.ProductId);
product.ProductCost = Convert.ToDecimal(item.UnitPriceOnDocument);
}
return await ctx.ShippingDocuments.GetByIdAsync(shippingDocument.Id, shippingDocument.Shipping != null || shippingDocument.Partner != null);
}

View File

@ -53,8 +53,9 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
/// <param name="audioStream">The audio file stream</param>
/// <param name="fileName">The original filename (used to determine format)</param>
/// <param name="language">Optional language code (e.g., "en", "hu"). If null, auto-detects.</param>
/// <param name="customPrompt">Optional vocabulary hints to help Whisper recognize specific words/names</param>
/// <returns>The transcribed text</returns>
public async Task<string?> TranscribeAudioAsync(Stream audioStream, string fileName, string? language = null)
public async Task<string?> TranscribeAudioAsync(Stream audioStream, string fileName, string? language = null, string? customPrompt = null)
{
try
{
@ -88,6 +89,22 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
form.Add(new StringContent(language), "language");
}
// Add prompt with vocabulary hints for better transcription
// Use custom prompt if provided, otherwise use default produce vocabulary
var prompt = customPrompt ??
"Gyümölcsök és zöldségek: narancs, mandarin, citrom, alma, körte, szőlő, datolya, áfonya, " +
"paprika, fokhagyma, hagyma, paradicsom, uborka, saláta, menta, bazsalikom, " +
"mennyiségek: rekesz, láda, kilogram, darab";
// Truncate prompt if too long (Whisper has a limit)
if (prompt.Length > 224)
{
prompt = prompt.Substring(0, 224);
Console.WriteLine($"[Whisper] Prompt truncated to 224 characters");
}
form.Add(new StringContent(prompt), "prompt");
// Optional: Add response format (json is default, can also be text, srt, verbose_json, or vtt)
form.Add(new StringContent("json"), "response_format");
@ -806,7 +823,8 @@ namespace Nop.Plugin.Misc.FruitBankPlugin.Services
"'name' (string), " +
"'quantity' (int - the number of cartons, boxes or packages), " +
"'netWeight' (double - the net kilograms), " +
"'grossWeight' (double - the gross kilograms).\r \n \n" +
"'grossWeight' (double - the gross kilograms)," +
"'unitCost (double - the unit price of the product on the document)'.\r \n \n" +
"";
string systemPrompt = "You are an AI assistant of FRUITBANK that extracts text and structured data from images. " +