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)