From f65521b9abe010943898ba6675a809a64b54fa60 Mon Sep 17 00:00:00 2001 From: Peter Foster Date: Wed, 15 Apr 2026 02:44:12 +0100 Subject: [PATCH] Add layered price lookup and category auto-fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PriceLookupService: eBay live data → saved listing history → AI estimate, each result labelled by source so the user knows how reliable it is - Revalue row: new "Check eBay" button fetches suggestion and pre-populates the price field; shows source label beneath (or "No suggestion available") - Category auto-fill: AutoFillCategoryAsync takes the top eBay category suggestion and fills the field automatically after photo analysis or AI title generation; dropdown stays visible so user can override Co-Authored-By: Claude Sonnet 4.6 --- .../Services/PriceLookupService.cs | 83 +++++++ EbayListingTool/Views/MainWindow.xaml.cs | 6 +- EbayListingTool/Views/SavedListingsView.xaml | 73 ++++++- .../Views/SavedListingsView.xaml.cs | 202 +++++++++++++----- EbayListingTool/Views/SingleItemView.xaml.cs | 45 +++- 5 files changed, 340 insertions(+), 69 deletions(-) create mode 100644 EbayListingTool/Services/PriceLookupService.cs diff --git a/EbayListingTool/Services/PriceLookupService.cs b/EbayListingTool/Services/PriceLookupService.cs new file mode 100644 index 0000000..8c03023 --- /dev/null +++ b/EbayListingTool/Services/PriceLookupService.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; +using EbayListingTool.Models; + +namespace EbayListingTool.Services; + +public record PriceSuggestion(decimal Price, string Source, string Label); + +/// +/// Layered price suggestion: eBay live data → own listing history → AI estimate. +/// Returns the first source that produces a result, labelled so the UI can show +/// where the suggestion came from. +/// +public class PriceLookupService +{ + private readonly EbayPriceResearchService _ebay; + private readonly SavedListingsService _history; + private readonly AiAssistantService _ai; + + private static readonly Regex PriceRegex = + new(@"PRICE:\s*(\d+\.?\d*)", RegexOptions.IgnoreCase); + + public PriceLookupService( + EbayPriceResearchService ebay, + SavedListingsService history, + AiAssistantService ai) + { + _ebay = ebay; + _history = history; + _ai = ai; + } + + public async Task GetSuggestionAsync(SavedListing listing) + { + // 1. eBay live listings + try + { + var result = await _ebay.GetLivePricesAsync(listing.Title); + if (result.HasSuggestion) + return new PriceSuggestion( + result.Suggested, + "ebay", + $"eBay suggests £{result.Suggested:F2} (from {result.Count} listings)"); + } + catch { /* eBay unavailable — fall through */ } + + // 2. Own saved listing history — same category, at least 2 data points + var sameCat = _history.Listings + .Where(l => l.Id != listing.Id + && !string.IsNullOrWhiteSpace(l.Category) + && l.Category.Equals(listing.Category, StringComparison.OrdinalIgnoreCase) + && l.Price > 0) + .Select(l => l.Price) + .ToList(); + + if (sameCat.Count >= 2) + { + var avg = Math.Round(sameCat.Average(), 2); + return new PriceSuggestion( + avg, + "history", + $"Your avg for {listing.Category}: £{avg:F2} ({sameCat.Count} listings)"); + } + + // 3. AI estimate + try + { + var response = await _ai.SuggestPriceAsync(listing.Title, listing.ConditionNotes); + var match = PriceRegex.Match(response); + if (match.Success + && decimal.TryParse(match.Groups[1].Value, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, + out var price) + && price > 0) + { + return new PriceSuggestion(price, "ai", $"AI estimate: £{price:F2}"); + } + } + catch { /* AI unavailable */ } + + return null; + } +} diff --git a/EbayListingTool/Views/MainWindow.xaml.cs b/EbayListingTool/Views/MainWindow.xaml.cs index 09965b5..d1175c3 100644 --- a/EbayListingTool/Views/MainWindow.xaml.cs +++ b/EbayListingTool/Views/MainWindow.xaml.cs @@ -15,6 +15,7 @@ public partial class MainWindow : MetroWindow private readonly BulkImportService _bulkService; private readonly SavedListingsService _savedService; private readonly EbayPriceResearchService _priceService; + private readonly PriceLookupService _priceLookupService; public MainWindow() { @@ -27,14 +28,15 @@ public partial class MainWindow : MetroWindow _aiService = new AiAssistantService(config); _bulkService = new BulkImportService(); _savedService = new SavedListingsService(); - _priceService = new EbayPriceResearchService(_auth); + _priceService = new EbayPriceResearchService(_auth); + _priceLookupService = new PriceLookupService(_priceService, _savedService, _aiService); // Photo Analysis tab — no eBay needed PhotoView.Initialise(_aiService, _savedService, _priceService); PhotoView.UseDetailsRequested += OnUseDetailsRequested; // Saved Listings tab - SavedView.Initialise(_savedService); + SavedView.Initialise(_savedService, _priceLookupService); // New Listing + Bulk tabs SingleView.Initialise(_listingService, _categoryService, _aiService, _auth); diff --git a/EbayListingTool/Views/SavedListingsView.xaml b/EbayListingTool/Views/SavedListingsView.xaml index 4827352..c286413 100644 --- a/EbayListingTool/Views/SavedListingsView.xaml +++ b/EbayListingTool/Views/SavedListingsView.xaml @@ -205,13 +205,69 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -362,6 +418,9 @@ + diff --git a/EbayListingTool/Views/SavedListingsView.xaml.cs b/EbayListingTool/Views/SavedListingsView.xaml.cs index c4bc263..0c4a1d9 100644 --- a/EbayListingTool/Views/SavedListingsView.xaml.cs +++ b/EbayListingTool/Views/SavedListingsView.xaml.cs @@ -13,6 +13,7 @@ namespace EbayListingTool.Views; public partial class SavedListingsView : UserControl { private SavedListingsService? _service; + private PriceLookupService? _priceLookup; private SavedListing? _selected; // Edit mode working state @@ -33,9 +34,10 @@ public partial class SavedListingsView : UserControl }; } - public void Initialise(SavedListingsService service) + public void Initialise(SavedListingsService service, PriceLookupService? priceLookup = null) { - _service = service; + _service = service; + _priceLookup = priceLookup; RefreshList(); } @@ -255,6 +257,10 @@ public partial class SavedListingsView : UserControl EmptyDetail.Visibility = Visibility.Collapsed; DetailPanel.Visibility = Visibility.Visible; + // Reset revalue UI + PriceDisplayRow.Visibility = Visibility.Visible; + RevalueRow.Visibility = Visibility.Collapsed; + DetailTitle.Text = listing.Title; DetailPrice.Text = listing.PriceDisplay; DetailCategory.Text = listing.Category; @@ -332,6 +338,77 @@ public partial class SavedListingsView : UserControl catch { } } + // ---- Quick revalue ---- + + private void RevalueBtn_Click(object sender, RoutedEventArgs e) + { + if (_selected == null) return; + RevaluePrice.Value = (double)_selected.Price; + PriceSuggestionLabel.Visibility = Visibility.Collapsed; + PriceSuggestionLabel.Text = ""; + CheckEbayBtn.IsEnabled = _priceLookup != null; + PriceDisplayRow.Visibility = Visibility.Collapsed; + RevalueRow.Visibility = Visibility.Visible; + RevaluePrice.Focus(); + } + + private void RevalueSave_Click(object sender, RoutedEventArgs e) + { + if (_selected == null || _service == null) return; + _selected.Price = (decimal)(RevaluePrice.Value ?? 0); + _service.Update(_selected); + DetailPrice.Text = _selected.PriceDisplay; + PriceDisplayRow.Visibility = Visibility.Visible; + RevalueRow.Visibility = Visibility.Collapsed; + PriceSuggestionLabel.Visibility = Visibility.Collapsed; + RefreshList(); + } + + private void RevalueCancel_Click(object sender, RoutedEventArgs e) + { + PriceDisplayRow.Visibility = Visibility.Visible; + RevalueRow.Visibility = Visibility.Collapsed; + PriceSuggestionLabel.Visibility = Visibility.Collapsed; + } + + private async void CheckEbayBtn_Click(object sender, RoutedEventArgs e) + { + if (_selected == null || _priceLookup == null) return; + + CheckEbayBtn.IsEnabled = false; + CheckEbayIcon.Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.Loading; + CheckEbayText.Text = "Checking…"; + PriceSuggestionLabel.Visibility = Visibility.Collapsed; + + try + { + var suggestion = await _priceLookup.GetSuggestionAsync(_selected); + + if (suggestion != null) + { + RevaluePrice.Value = (double)suggestion.Price; + PriceSuggestionLabel.Text = suggestion.Label; + PriceSuggestionLabel.Visibility = Visibility.Visible; + } + else + { + PriceSuggestionLabel.Text = "No suggestion available — enter price manually."; + PriceSuggestionLabel.Visibility = Visibility.Visible; + } + } + catch (Exception ex) + { + PriceSuggestionLabel.Text = $"Lookup failed: {ex.Message}"; + PriceSuggestionLabel.Visibility = Visibility.Visible; + } + finally + { + CheckEbayIcon.Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.Magnify; + CheckEbayText.Text = "Check eBay"; + CheckEbayBtn.IsEnabled = true; + } + } + // ---- Edit mode ---- private void EditListing_Click(object sender, RoutedEventArgs e) @@ -372,9 +449,17 @@ public partial class SavedListingsView : UserControl var path = _editPhotoPaths[i]; var index = i; // capture for lambdas - var container = new Grid { Width = 120, Height = 120, Margin = new Thickness(0, 0, 8, 0) }; + // Outer StackPanel: photo tile on top, reorder buttons below + var outer = new StackPanel + { + Orientation = Orientation.Vertical, + Margin = new Thickness(0, 0, 8, 0), + Width = 120 + }; + + // --- Photo tile (image + cover badge + remove button) --- + var photoGrid = new Grid { Width = 120, Height = 120 }; - // Photo image var imgBorder = new Border { Width = 120, Height = 120, @@ -403,9 +488,9 @@ public partial class SavedListingsView : UserControl AddPhotoIcon(imgBorder); } - container.Children.Add(imgBorder); + photoGrid.Children.Add(imgBorder); - // "Cover" badge on the first photo + // "Cover" badge — top-left, only on first photo if (i == 0) { var badge = new Border @@ -418,10 +503,10 @@ public partial class SavedListingsView : UserControl Margin = new Thickness(4, 4, 0, 0) }; badge.Child = new TextBlock { Text = "Cover", FontSize = 9, Foreground = Brushes.White }; - container.Children.Add(badge); + photoGrid.Children.Add(badge); } - // Remove (×) button — top-right corner + // Remove (×) button — top-right corner of image var removeBtn = new Button { Content = "×", @@ -442,66 +527,69 @@ public partial class SavedListingsView : UserControl _editPhotoPaths.RemoveAt(index); BuildEditPhotoStrip(); }; - container.Children.Add(removeBtn); + photoGrid.Children.Add(removeBtn); - // Left/right reorder buttons — bottom-centre + outer.Children.Add(photoGrid); + + // --- Reorder buttons below the photo --- var reorderPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Bottom, - Margin = new Thickness(0, 0, 0, 4) + Margin = new Thickness(0, 4, 0, 0) }; - if (i > 0) + var leftBtn = new Button { - var leftBtn = new Button - { - Content = "◀", - Width = 26, Height = 20, - FontSize = 9, - Padding = new Thickness(0), - Margin = new Thickness(0, 0, 2, 0), - Style = (Style)FindResource("MahApps.Styles.Button.Square"), - Foreground = Brushes.White, - Background = new SolidColorBrush(Color.FromArgb(180, 40, 40, 40)), - ToolTip = "Move left" - }; - leftBtn.Click += (s, e) => - { - (_editPhotoPaths[index], _editPhotoPaths[index - 1]) = - (_editPhotoPaths[index - 1], _editPhotoPaths[index]); - BuildEditPhotoStrip(); - }; - reorderPanel.Children.Add(leftBtn); - } - - if (i < _editPhotoPaths.Count - 1) + Width = 52, Height = 24, + FontSize = 10, + Padding = new Thickness(0), + Margin = new Thickness(0, 0, 2, 0), + Style = (Style)FindResource("MahApps.Styles.Button.Square"), + ToolTip = "Move left", + IsEnabled = i > 0 + }; + leftBtn.Content = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center }; + ((StackPanel)leftBtn.Content).Children.Add(new MahApps.Metro.IconPacks.PackIconMaterial { - var rightBtn = new Button - { - Content = "▶", - Width = 26, Height = 20, - FontSize = 9, - Padding = new Thickness(0), - Style = (Style)FindResource("MahApps.Styles.Button.Square"), - Foreground = Brushes.White, - Background = new SolidColorBrush(Color.FromArgb(180, 40, 40, 40)), - ToolTip = "Move right" - }; - rightBtn.Click += (s, e) => - { - (_editPhotoPaths[index], _editPhotoPaths[index + 1]) = - (_editPhotoPaths[index + 1], _editPhotoPaths[index]); - BuildEditPhotoStrip(); - }; - reorderPanel.Children.Add(rightBtn); - } + Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronLeft, + Width = 12, Height = 12, VerticalAlignment = VerticalAlignment.Center + }); + ((StackPanel)leftBtn.Content).Children.Add(new TextBlock { Text = "Move", FontSize = 9, Margin = new Thickness(2, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }); + leftBtn.Click += (s, e) => + { + (_editPhotoPaths[index], _editPhotoPaths[index - 1]) = + (_editPhotoPaths[index - 1], _editPhotoPaths[index]); + BuildEditPhotoStrip(); + }; + reorderPanel.Children.Add(leftBtn); - if (reorderPanel.Children.Count > 0) - container.Children.Add(reorderPanel); + var rightBtn = new Button + { + Width = 52, Height = 24, + FontSize = 10, + Padding = new Thickness(0), + Style = (Style)FindResource("MahApps.Styles.Button.Square"), + ToolTip = "Move right", + IsEnabled = i < _editPhotoPaths.Count - 1 + }; + rightBtn.Content = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center }; + ((StackPanel)rightBtn.Content).Children.Add(new TextBlock { Text = "Move", FontSize = 9, Margin = new Thickness(0, 0, 2, 0), VerticalAlignment = VerticalAlignment.Center }); + ((StackPanel)rightBtn.Content).Children.Add(new MahApps.Metro.IconPacks.PackIconMaterial + { + Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronRight, + Width = 12, Height = 12, VerticalAlignment = VerticalAlignment.Center + }); + rightBtn.Click += (s, e) => + { + (_editPhotoPaths[index], _editPhotoPaths[index + 1]) = + (_editPhotoPaths[index + 1], _editPhotoPaths[index]); + BuildEditPhotoStrip(); + }; + reorderPanel.Children.Add(rightBtn); - EditPhotosPanel.Children.Add(container); + outer.Children.Add(reorderPanel); + EditPhotosPanel.Children.Add(outer); } // "Add photos" button at the end of the strip diff --git a/EbayListingTool/Views/SingleItemView.xaml.cs b/EbayListingTool/Views/SingleItemView.xaml.cs index ea333eb..bce2b3d 100644 --- a/EbayListingTool/Views/SingleItemView.xaml.cs +++ b/EbayListingTool/Views/SingleItemView.xaml.cs @@ -18,6 +18,7 @@ public partial class SingleItemView : UserControl private ListingDraft _draft = new(); private System.Threading.CancellationTokenSource? _categoryCts; + private bool _suppressCategoryLookup; private string _suggestedPriceValue = ""; // Photo drag-reorder @@ -50,7 +51,7 @@ public partial class SingleItemView : UserControl } /// Pre-fills the form from a Photo Analysis result. - public void PopulateFromAnalysis(PhotoAnalysisResult result, IReadOnlyList imagePaths, decimal price) + public async void PopulateFromAnalysis(PhotoAnalysisResult result, IReadOnlyList imagePaths, decimal price) { // Q6: reset form directly — calling NewListing_Click shows a confirmation dialog which // is unexpected when arriving here automatically from the Photo Analysis tab. @@ -71,9 +72,9 @@ public partial class SingleItemView : UserControl TitleBox.Text = result.Title; DescriptionBox.Text = result.Description; PriceBox.Value = (double)price; - CategoryBox.Text = result.CategoryKeyword; - _draft.CategoryName = result.CategoryKeyword; + // Auto-fill the top eBay category from the analysis keyword; user can override + await AutoFillCategoryAsync(result.CategoryKeyword); // Q1: load all photos from analysis var validPaths = imagePaths.Where(p => !string.IsNullOrEmpty(p) && File.Exists(p)).ToArray(); @@ -117,6 +118,10 @@ public partial class SingleItemView : UserControl { var title = await _aiService.GenerateTitleAsync(current, condition); TitleBox.Text = title.Trim().TrimEnd('.').Trim('"'); + + // Auto-fill category from the generated title if not already set + if (string.IsNullOrWhiteSpace(_draft.CategoryId)) + await AutoFillCategoryAsync(TitleBox.Text); } catch (Exception ex) { @@ -129,6 +134,8 @@ public partial class SingleItemView : UserControl private async void CategoryBox_TextChanged(object sender, TextChangedEventArgs e) { + if (_suppressCategoryLookup) return; + _categoryCts?.Cancel(); _categoryCts?.Dispose(); _categoryCts = new System.Threading.CancellationTokenSource(); @@ -211,6 +218,38 @@ public partial class SingleItemView : UserControl } } + /// + /// Fetches the top eBay category suggestion for and auto-fills + /// the category fields. The suggestions list is shown so the user can override. + /// + private async Task AutoFillCategoryAsync(string keyword) + { + if (_categoryService == null || string.IsNullOrWhiteSpace(keyword)) return; + + try + { + var suggestions = await _categoryService.GetCategorySuggestionsAsync(keyword); + if (suggestions.Count == 0) return; + + var top = suggestions[0]; + _suppressCategoryLookup = true; + try + { + _draft.CategoryId = top.CategoryId; + _draft.CategoryName = top.CategoryName; + CategoryBox.Text = top.CategoryName; + CategoryIdLabel.Text = $"ID: {top.CategoryId}"; + } + finally { _suppressCategoryLookup = false; } + + // Show the full list so user can see alternatives and override + CategorySuggestionsList.ItemsSource = suggestions; + CategorySuggestionsList.Visibility = suggestions.Count > 1 + ? Visibility.Visible : Visibility.Collapsed; + } + catch { /* non-critical — leave category blank if lookup fails */ } + } + // ---- Condition ---- private void ConditionBox_SelectionChanged(object sender, SelectionChangedEventArgs e)