diff --git a/EbayListingTool/Services/EbayAuthService.cs b/EbayListingTool/Services/EbayAuthService.cs index f0167b2..68a9e6c 100644 --- a/EbayListingTool/Services/EbayAuthService.cs +++ b/EbayListingTool/Services/EbayAuthService.cs @@ -15,6 +15,10 @@ 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 static readonly string[] Scopes = [ "https://api.ebay.com/oauth/api_scope", @@ -191,6 +195,47 @@ 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() + { + 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"; + + 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 + { + ["grant_type"] = "client_credentials", + ["scope"] = "https://api.ebay.com/oauth/api_scope" + }); + + var response = await http.PostAsync(tokenUrl, body); + var json = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"App token request failed: {json}"); + + 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; + } + public void Disconnect() { _token = null; diff --git a/EbayListingTool/Services/EbayPriceResearchService.cs b/EbayListingTool/Services/EbayPriceResearchService.cs new file mode 100644 index 0000000..f45095f --- /dev/null +++ b/EbayListingTool/Services/EbayPriceResearchService.cs @@ -0,0 +1,80 @@ +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; + public decimal Suggested => Prices.Count > 0 ? Percentile(40) : 0; // 40th pct: competitive but not lowest + + 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; + private static readonly HttpClient _http = new(); + + public EbayPriceResearchService(EbayAuthService auth) + { + _auth = auth; + } + + public async Task GetLivePricesAsync(string query, int limit = 20) + { + if (string.IsNullOrWhiteSpace(query)) + return new LivePriceResult(); + + var token = await _auth.GetAppTokenAsync(); + var baseUrl = _auth.BaseUrl; + + 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}" + + $"&sort=price"); + + 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}): {json}"); + + 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); + } + } + + return new LivePriceResult { Prices = prices }; + } +} 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 af741cc..7c24000 100644 --- a/EbayListingTool/Views/PhotoAnalysisView.xaml +++ b/EbayListingTool/Views/PhotoAnalysisView.xaml @@ -490,6 +490,18 @@ StringFormat="F2" Interval="0.5"/> + + + + + + _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) { @@ -344,11 +349,58 @@ 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; + + // Show spinner + LivePriceRow.Visibility = Visibility.Visible; + LivePriceSpinner.Visibility = Visibility.Visible; + LivePriceStatus.Text = "Checking live eBay UK prices…"; + + try + { + 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)suggested; + 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) + { + LivePriceSpinner.Visibility = Visibility.Collapsed; + LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}"; + } + } + private void TitleBox_TextChanged(object sender, TextChangedEventArgs e) { var len = TitleBox.Text.Length;