diff --git a/EbayListingTool/Services/AiAssistantService.cs b/EbayListingTool/Services/AiAssistantService.cs
index aa0466d..86e4cb0 100644
--- a/EbayListingTool/Services/AiAssistantService.cs
+++ b/EbayListingTool/Services/AiAssistantService.cs
@@ -16,7 +16,7 @@ public class AiAssistantService
{
private readonly string _apiKey;
private readonly string _model;
- private static readonly HttpClient _http = new();
+ private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(90) };
private const string ApiUrl = "https://openrouter.ai/api/v1/chat/completions";
@@ -134,6 +134,60 @@ public class AiAssistantService
}
}
+ ///
+ /// Takes an existing AI-generated listing and rewrites title, description and price
+ /// to incorporate user corrections (e.g. "earrings are white gold, not silver").
+ /// Returns the updated fields; other PhotoAnalysisResult fields are unchanged.
+ ///
+ public async Task<(string Title, string Description, decimal Price, string PriceReasoning)>
+ RefineWithCorrectionsAsync(string title, string description, decimal price, string corrections)
+ {
+ var priceContext = price > 0
+ ? $"Current price: £{price:F2}\n\n"
+ : "";
+
+ var prompt =
+ "You are an expert eBay UK seller. I have a listing with incorrect details that need fixing.\n\n" +
+ $"Current title: {title}\n" +
+ $"Current description:\n{description}\n\n" +
+ priceContext +
+ $"CORRECTIONS FROM SELLER: {corrections}\n\n" +
+ "Rewrite the listing incorporating these corrections. " +
+ "Return ONLY valid JSON — no markdown, no explanation:\n" +
+ "{\n" +
+ " \"title\": \"corrected eBay UK title, max 80 chars, keyword-rich\",\n" +
+ " \"description\": \"corrected full plain-text eBay UK description\",\n" +
+ " \"price_suggested\": 29.99,\n" +
+ " \"price_reasoning\": \"one sentence why this price\"\n" +
+ "}\n\n" +
+ "price_suggested MUST be a realistic GBP amount greater than 0. Never return 0.";
+
+ var json = await CallAsync(prompt);
+
+ try
+ {
+ JObject obj;
+ try { obj = JObject.Parse(json.Trim()); }
+ catch (JsonReaderException)
+ {
+ var m = System.Text.RegularExpressions.Regex.Match(json, @"```(?:json)?\s*(\{[\s\S]*?\})\s*```");
+ obj = JObject.Parse(m.Success ? m.Groups[1].Value : json.Trim());
+ }
+
+ var parsedPrice = obj["price_suggested"]?.Value() ?? 0;
+ return (
+ obj["title"]?.ToString() ?? title,
+ obj["description"]?.ToString() ?? description,
+ parsedPrice > 0 ? parsedPrice : price, // treat 0 as "not provided", keep original
+ obj["price_reasoning"]?.ToString() ?? ""
+ );
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Could not parse AI refinement response: {ex.Message}");
+ }
+ }
+
/// Convenience wrapper — analyses a single photo.
public Task AnalyseItemFromPhotoAsync(string imagePath)
=> AnalyseItemFromPhotosAsync(new[] { imagePath });
diff --git a/EbayListingTool/Services/EbayAuthService.cs b/EbayListingTool/Services/EbayAuthService.cs
index f0167b2..6552324 100644
--- a/EbayListingTool/Services/EbayAuthService.cs
+++ b/EbayListingTool/Services/EbayAuthService.cs
@@ -15,6 +15,14 @@ public class EbayAuthService
private readonly string _tokenPath;
private EbayToken? _token;
+ // App-level (client credentials) token — no user login needed, used for Browse API
+ private string? _appToken;
+ private DateTime _appTokenExpiry = DateTime.MinValue;
+ private readonly SemaphoreSlim _appTokenLock = new(1, 1); // prevents concurrent token fetches
+
+ // Shared HttpClient for all auth/token calls — avoids socket exhaustion from per-call `new HttpClient()`
+ private static readonly HttpClient _http = new();
+
private static readonly string[] Scopes =
[
"https://api.ebay.com/oauth/api_scope",
@@ -110,19 +118,19 @@ public class EbayAuthService
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
: "https://api.ebay.com/identity/v1/oauth2/token";
- using var http = new HttpClient();
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
- http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
- var body = new FormUrlEncodedContent(new Dictionary
+ using var codeRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
+ codeRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
+ codeRequest.Content = new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = _settings.RuName
});
- var response = await http.PostAsync(tokenUrl, body);
+ var response = await _http.SendAsync(codeRequest);
var json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
@@ -148,19 +156,19 @@ public class EbayAuthService
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
: "https://api.ebay.com/identity/v1/oauth2/token";
- using var http = new HttpClient();
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
- http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
- var body = new FormUrlEncodedContent(new Dictionary
+ using var refreshRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
+ refreshRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
+ refreshRequest.Content = new FormUrlEncodedContent(new Dictionary
{
- ["grant_type"] = "refresh_token",
+ ["grant_type"] = "refresh_token",
["refresh_token"] = _token!.RefreshToken,
- ["scope"] = string.Join(" ", Scopes)
+ ["scope"] = string.Join(" ", Scopes)
});
- var response = await http.PostAsync(tokenUrl, body);
+ var response = await _http.SendAsync(refreshRequest);
var json = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
@@ -176,12 +184,13 @@ public class EbayAuthService
{
try
{
- using var http = new HttpClient();
- http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = _settings.Sandbox
? "https://apiz.sandbox.ebay.com/commerce/identity/v1/user/"
: "https://apiz.ebay.com/commerce/identity/v1/user/";
- var json = await http.GetStringAsync(url);
+ using var idRequest = new HttpRequestMessage(HttpMethod.Get, url);
+ idRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+ var idResponse = await _http.SendAsync(idRequest);
+ var json = await idResponse.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
return obj["username"]?.ToString() ?? "Unknown";
}
@@ -191,6 +200,60 @@ public class EbayAuthService
}
}
+ ///
+ /// Returns an application-level OAuth token (client credentials grant).
+ /// Does NOT require the user to be logged in — uses Client ID + Secret only.
+ /// Cached for the token lifetime minus a 5-minute safety margin.
+ ///
+ public async Task GetAppTokenAsync()
+ {
+ // Fast path — no lock needed for a read when token is valid
+ if (_appToken != null && DateTime.UtcNow < _appTokenExpiry)
+ return _appToken;
+
+ await _appTokenLock.WaitAsync();
+ try
+ {
+ // Re-check inside lock (another thread may have refreshed while we waited)
+ if (_appToken != null && DateTime.UtcNow < _appTokenExpiry)
+ return _appToken;
+
+ if (string.IsNullOrEmpty(_settings.ClientId) || string.IsNullOrEmpty(_settings.ClientSecret))
+ throw new InvalidOperationException("eBay Client ID / Secret not configured in appsettings.json.");
+
+ var tokenUrl = _settings.Sandbox
+ ? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
+ : "https://api.ebay.com/identity/v1/oauth2/token";
+
+ var credentials = Convert.ToBase64String(
+ Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
+ request.Content = new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "client_credentials",
+ ["scope"] = "https://api.ebay.com/oauth/api_scope"
+ });
+
+ var response = await _http.SendAsync(request);
+ var json = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"App token request failed ({(int)response.StatusCode})");
+
+ var obj = JObject.Parse(json);
+ _appToken = obj["access_token"]!.ToString();
+ var expiresIn = obj["expires_in"]?.Value() ?? 7200;
+ _appTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn - 300); // 5-min buffer
+ return _appToken;
+ }
+ finally
+ {
+ _appTokenLock.Release();
+ }
+ }
+
public void Disconnect()
{
_token = null;
diff --git a/EbayListingTool/Services/EbayPriceResearchService.cs b/EbayListingTool/Services/EbayPriceResearchService.cs
new file mode 100644
index 0000000..9fa47d2
--- /dev/null
+++ b/EbayListingTool/Services/EbayPriceResearchService.cs
@@ -0,0 +1,97 @@
+using System.Net.Http.Headers;
+using Newtonsoft.Json.Linq;
+
+namespace EbayListingTool.Services;
+
+public class LivePriceResult
+{
+ public List Prices { get; init; } = new();
+ public int Count => Prices.Count;
+ public decimal Min => Prices.Count > 0 ? Prices.Min() : 0;
+ public decimal Max => Prices.Count > 0 ? Prices.Max() : 0;
+ public decimal Median => Prices.Count > 0 ? Percentile(50) : 0;
+
+ // Require ≥5 samples before offering a suggestion; 40th pct = competitive but not cheapest
+ public decimal Suggested => Prices.Count >= MinSampleSize ? Percentile(40) : 0;
+ public bool HasSuggestion => Prices.Count >= MinSampleSize;
+
+ internal const int MinSampleSize = 5;
+
+ private decimal Percentile(int pct)
+ {
+ var sorted = Prices.OrderBy(p => p).ToList();
+ int idx = (int)Math.Round((pct / 100.0) * (sorted.Count - 1));
+ return sorted[Math.Clamp(idx, 0, sorted.Count - 1)];
+ }
+}
+
+///
+/// Uses the eBay Browse API (app-level token, no user login required) to fetch
+/// current Buy It Now prices for similar items on eBay UK.
+///
+public class EbayPriceResearchService
+{
+ private readonly EbayAuthService _auth;
+
+ // 15-second timeout — price lookup is non-critical; don't leave spinner hanging
+ private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
+
+ public EbayPriceResearchService(EbayAuthService auth)
+ {
+ _auth = auth;
+ }
+
+ public async Task GetLivePricesAsync(string query, int limit = 30)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ return new LivePriceResult();
+
+ var token = await _auth.GetAppTokenAsync();
+ var baseUrl = _auth.BaseUrl;
+
+ // Omit sort= to use eBay BEST_MATCH — price-sorted results are biased toward
+ // the cheapest (broken/spam) listings; BEST_MATCH gives a more representative sample
+ using var request = new HttpRequestMessage(HttpMethod.Get,
+ $"{baseUrl}/buy/browse/v1/item_summary/search" +
+ $"?q={Uri.EscapeDataString(query)}" +
+ $"&filter=buyingOptions%3A%7BFIXED_PRICE%7D" +
+ $"&limit={limit}");
+
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
+
+ var response = await _http.SendAsync(request);
+ var json = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"eBay Browse API error ({(int)response.StatusCode})");
+
+ var obj = JObject.Parse(json);
+ var items = obj["itemSummaries"] as JArray ?? new JArray();
+ var prices = new List();
+
+ foreach (var item in items)
+ {
+ var priceVal = item["price"]?["value"]?.ToString();
+ var currency = item["price"]?["currency"]?.ToString();
+ if (currency == "GBP" && decimal.TryParse(priceVal,
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture,
+ out var p) && p > 0)
+ {
+ prices.Add(p);
+ }
+ }
+
+ // Trim top and bottom 10% to remove outliers (spammers, broken items, typos).
+ // Only trim when there are enough data points to make it worthwhile.
+ if (prices.Count >= 10)
+ {
+ prices.Sort();
+ int trim = (int)Math.Floor(prices.Count * 0.10);
+ prices = prices.Skip(trim).Take(prices.Count - 2 * trim).ToList();
+ }
+
+ return new LivePriceResult { Prices = prices };
+ }
+}
diff --git a/EbayListingTool/Views/BulkImportView.xaml b/EbayListingTool/Views/BulkImportView.xaml
index 3424ee7..fa61e1f 100644
--- a/EbayListingTool/Views/BulkImportView.xaml
+++ b/EbayListingTool/Views/BulkImportView.xaml
@@ -198,7 +198,7 @@
Background="{DynamicResource MahApps.Brushes.Gray8}"
VerticalAlignment="Center">
-
-
@@ -278,7 +278,7 @@
-
diff --git a/EbayListingTool/Views/MainWindow.xaml.cs b/EbayListingTool/Views/MainWindow.xaml.cs
index 375dcf4..6e4bde5 100644
--- a/EbayListingTool/Views/MainWindow.xaml.cs
+++ b/EbayListingTool/Views/MainWindow.xaml.cs
@@ -14,6 +14,7 @@ public partial class MainWindow : MetroWindow
private readonly AiAssistantService _aiService;
private readonly BulkImportService _bulkService;
private readonly SavedListingsService _savedService;
+ private readonly EbayPriceResearchService _priceService;
public MainWindow()
{
@@ -26,9 +27,10 @@ public partial class MainWindow : MetroWindow
_aiService = new AiAssistantService(config);
_bulkService = new BulkImportService();
_savedService = new SavedListingsService();
+ _priceService = new EbayPriceResearchService(_auth);
// Photo Analysis tab — no eBay needed
- PhotoView.Initialise(_aiService, _savedService);
+ PhotoView.Initialise(_aiService, _savedService, _priceService);
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
// Saved Listings tab
diff --git a/EbayListingTool/Views/PhotoAnalysisView.xaml b/EbayListingTool/Views/PhotoAnalysisView.xaml
index 6fb16d2..7c24000 100644
--- a/EbayListingTool/Views/PhotoAnalysisView.xaml
+++ b/EbayListingTool/Views/PhotoAnalysisView.xaml
@@ -490,6 +490,18 @@
StringFormat="F2" Interval="0.5"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/Views/PhotoAnalysisView.xaml.cs b/EbayListingTool/Views/PhotoAnalysisView.xaml.cs
index f55a34b..224c041 100644
--- a/EbayListingTool/Views/PhotoAnalysisView.xaml.cs
+++ b/EbayListingTool/Views/PhotoAnalysisView.xaml.cs
@@ -12,8 +12,9 @@ namespace EbayListingTool.Views;
public partial class PhotoAnalysisView : UserControl
{
- private AiAssistantService? _aiService;
- private SavedListingsService? _savedService;
+ private AiAssistantService? _aiService;
+ private SavedListingsService? _savedService;
+ private EbayPriceResearchService? _priceService;
private List _currentImagePaths = new();
private PhotoAnalysisResult? _lastResult;
private int _activePhotoIndex = 0;
@@ -50,10 +51,12 @@ public partial class PhotoAnalysisView : UserControl
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
}
- public void Initialise(AiAssistantService aiService, SavedListingsService savedService)
+ public void Initialise(AiAssistantService aiService, SavedListingsService savedService,
+ EbayPriceResearchService priceService)
{
_aiService = aiService;
_savedService = savedService;
+ _priceService = priceService;
}
// ---- Photo clip geometry sync ----
@@ -204,6 +207,8 @@ public partial class PhotoAnalysisView : UserControl
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
_lastResult = result;
ShowResults(result);
+ // Fire live price lookup in background — updates price display when ready
+ _ = UpdateLivePricesAsync(result.Title);
}
catch (Exception ex)
{
@@ -220,6 +225,64 @@ public partial class PhotoAnalysisView : UserControl
private void ReAnalyse_Click(object sender, RoutedEventArgs e)
=> Analyse_Click(sender, e);
+ private async void Refine_Click(object sender, RoutedEventArgs e)
+ {
+ if (_aiService == null || _lastResult == null) return;
+
+ var corrections = CorrectionsBox.Text.Trim();
+ if (string.IsNullOrEmpty(corrections))
+ {
+ CorrectionsBox.Focus();
+ return;
+ }
+
+ var title = TitleBox.Text;
+ var description = DescriptionBox.Text;
+ var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
+
+ SetRefining(true);
+ try
+ {
+ var (newTitle, newDesc, newPrice, newReasoning) =
+ await _aiService.RefineWithCorrectionsAsync(title, description, price, corrections);
+
+ TitleBox.Text = newTitle;
+ DescriptionBox.Text = newDesc;
+ PriceOverride.Value = (double)Math.Round(newPrice, 2); // Issue 6
+ PriceSuggestedText.Text = newPrice > 0 ? $"£{newPrice:F2}" : "—";
+
+ _lastResult.Title = newTitle;
+ _lastResult.Description = newDesc;
+ _lastResult.PriceSuggested = newPrice;
+
+ if (!string.IsNullOrWhiteSpace(newReasoning))
+ {
+ PriceReasoningText.Text = newReasoning;
+ PriceReasoningText.Visibility = Visibility.Visible;
+ }
+
+ // Clear the corrections box now they're applied
+ CorrectionsBox.Text = "";
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Refinement failed:\n\n{ex.Message}", "AI Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ finally
+ {
+ SetRefining(false);
+ }
+ }
+
+ private void SetRefining(bool busy)
+ {
+ RefineBtn.IsEnabled = !busy;
+ RefineIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
+ RefineSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+ RefineBtnText.Text = busy ? "Refining…" : "Refine with AI";
+ }
+
private void ShowResults(PhotoAnalysisResult r)
{
IdlePanel.Visibility = Visibility.Collapsed;
@@ -275,7 +338,7 @@ public partial class PhotoAnalysisView : UserControl
PriceRangeBar.Visibility = Visibility.Collapsed;
}
- PriceOverride.Value = (double)r.PriceSuggested;
+ PriceOverride.Value = (double)Math.Round(r.PriceSuggested, 2); // Issue 6
// Price reasoning
PriceReasoningText.Text = r.PriceReasoning;
@@ -286,11 +349,69 @@ public partial class PhotoAnalysisView : UserControl
TitleBox.Text = r.Title;
DescriptionBox.Text = r.Description;
+ // Reset live price row until lookup completes
+ LivePriceRow.Visibility = Visibility.Collapsed;
+
// Animate results in
var sb = (Storyboard)FindResource("ResultsReveal");
sb.Begin(this);
}
+ private async Task UpdateLivePricesAsync(string query)
+ {
+ if (_priceService == null) return;
+
+ // Issue 7: guard against off-thread callers (fire-and-forget may lose sync context)
+ if (!Dispatcher.CheckAccess())
+ {
+ await Dispatcher.InvokeAsync(() => UpdateLivePricesAsync(query)).Task.Unwrap();
+ return;
+ }
+
+ try
+ {
+ // Issue 1: spinner-show inside try so a disposed control doesn't crash the caller
+ LivePriceRow.Visibility = Visibility.Visible;
+ LivePriceSpinner.Visibility = Visibility.Visible;
+ LivePriceStatus.Text = "Checking live eBay UK prices…";
+
+ var live = await _priceService.GetLivePricesAsync(query);
+
+ if (live.Count == 0)
+ {
+ LivePriceStatus.Text = "No matching live listings found.";
+ LivePriceSpinner.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ // Update range bar with real data
+ PriceMinText.Text = $"£{live.Min:F2}";
+ PriceMaxText.Text = $"£{live.Max:F2}";
+ PriceRangeBar.Visibility = Visibility.Visible;
+
+ // Update suggested price to 40th percentile (competitive but not cheapest)
+ var suggested = live.Suggested;
+ PriceSuggestedText.Text = $"£{suggested:F2}";
+ PriceOverride.Value = (double)Math.Round(suggested, 2); // Issue 6: avoid decimal→double drift
+ if (_lastResult != null) _lastResult.PriceSuggested = suggested;
+
+ // Update status label
+ LivePriceSpinner.Visibility = Visibility.Collapsed;
+ LivePriceStatus.Text =
+ $"Based on {live.Count} live eBay UK listing{(live.Count == 1 ? "" : "s")} " +
+ $"(range £{live.Min:F2} – £{live.Max:F2})";
+ }
+ catch (Exception ex)
+ {
+ try
+ {
+ LivePriceSpinner.Visibility = Visibility.Collapsed;
+ LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}";
+ }
+ catch { /* control may be unloaded by the time catch runs */ }
+ }
+ }
+
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
{
var len = TitleBox.Text.Length;
@@ -492,12 +613,13 @@ public partial class PhotoAnalysisView : UserControl
// ---- Save toast ----
- private bool _toastAnimating = false;
-
private void ShowSaveToast()
{
- if (_toastAnimating) return;
- _toastAnimating = true;
+ // Issue 8: always restart — stop any in-progress hold timer and cancel the running
+ // animation so the flag can never get permanently stuck and rapid saves feel responsive.
+ _holdTimer?.Stop();
+ _holdTimer = null;
+ ToastTranslate.BeginAnimation(TranslateTransform.YProperty, null); // cancel current animation
SaveToast.Visibility = Visibility.Visible;
@@ -521,7 +643,6 @@ public partial class PhotoAnalysisView : UserControl
slideOut.Completed += (_, _) =>
{
SaveToast.Visibility = Visibility.Collapsed;
- _toastAnimating = false;
};
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut);
};