diff --git a/EbayListingTool.sln b/EbayListingTool.sln
new file mode 100644
index 0000000..34ed390
--- /dev/null
+++ b/EbayListingTool.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.9.34701.34
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EbayListingTool", "EbayListingTool\EbayListingTool.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/EbayListingTool/App.xaml b/EbayListingTool/App.xaml
new file mode 100644
index 0000000..26a5f58
--- /dev/null
+++ b/EbayListingTool/App.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/App.xaml.cs b/EbayListingTool/App.xaml.cs
new file mode 100644
index 0000000..97541c7
--- /dev/null
+++ b/EbayListingTool/App.xaml.cs
@@ -0,0 +1,66 @@
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Threading;
+using Microsoft.Extensions.Configuration;
+
+namespace EbayListingTool;
+
+public partial class App : Application
+{
+ public static IConfiguration Configuration { get; private set; } = null!;
+
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ // Global handler for unhandled exceptions on the UI thread
+ DispatcherUnhandledException += OnDispatcherUnhandledException;
+
+ // Global handler for unhandled exceptions on background Task threads
+ TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
+
+ // Global handler for unhandled exceptions on non-UI threads
+ AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
+
+ Configuration = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
+ .Build();
+
+ base.OnStartup(e);
+ }
+
+ private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
+ {
+ MessageBox.Show(
+ $"An unexpected error occurred:\n\n{e.Exception.Message}",
+ "Unexpected Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ e.Handled = true; // Prevent crash; remove this line if you want fatal errors to still terminate
+ }
+
+ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ e.SetObserved();
+ Dispatcher.InvokeAsync(() =>
+ {
+ MessageBox.Show(
+ $"A background operation failed:\n\n{e.Exception.InnerException?.Message ?? e.Exception.Message}",
+ "Background Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ });
+ }
+
+ private void OnDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
+ {
+ var message = e.ExceptionObject is Exception ex
+ ? ex.Message
+ : e.ExceptionObject?.ToString() ?? "Unknown error";
+
+ MessageBox.Show(
+ $"A fatal error occurred and the application must close:\n\n{message}",
+ "Fatal Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+}
diff --git a/EbayListingTool/EbayListingTool.csproj b/EbayListingTool/EbayListingTool.csproj
new file mode 100644
index 0000000..e7d79a6
--- /dev/null
+++ b/EbayListingTool/EbayListingTool.csproj
@@ -0,0 +1,37 @@
+
+
+ WinExe
+ net8.0-windows
+ enable
+ enable
+ true
+ EbayListingTool
+ EbayListingTool
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
diff --git a/EbayListingTool/GlobalUsings.cs b/EbayListingTool/GlobalUsings.cs
new file mode 100644
index 0000000..8d94984
--- /dev/null
+++ b/EbayListingTool/GlobalUsings.cs
@@ -0,0 +1,8 @@
+global using System;
+global using System.Collections.Generic;
+global using System.IO;
+global using System.Linq;
+global using System.Net.Http;
+global using System.Text;
+global using System.Threading;
+global using System.Threading.Tasks;
diff --git a/EbayListingTool/Models/AppSettings.cs b/EbayListingTool/Models/AppSettings.cs
new file mode 100644
index 0000000..25db433
--- /dev/null
+++ b/EbayListingTool/Models/AppSettings.cs
@@ -0,0 +1,16 @@
+namespace EbayListingTool.Models;
+
+public class EbaySettings
+{
+ public string ClientId { get; set; } = "";
+ public string ClientSecret { get; set; } = "";
+ public string RuName { get; set; } = "";
+ public bool Sandbox { get; set; } = true;
+ public int RedirectPort { get; set; } = 8080;
+ public string DefaultPostcode { get; set; } = "";
+}
+
+public class AnthropicSettings
+{
+ public string ApiKey { get; set; } = "";
+}
diff --git a/EbayListingTool/Models/BulkImportRow.cs b/EbayListingTool/Models/BulkImportRow.cs
new file mode 100644
index 0000000..4e1e079
--- /dev/null
+++ b/EbayListingTool/Models/BulkImportRow.cs
@@ -0,0 +1,134 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace EbayListingTool.Models;
+
+public enum BulkRowStatus
+{
+ Pending,
+ Enhancing,
+ Ready,
+ Posting,
+ Posted,
+ Failed
+}
+
+public class BulkImportRow : INotifyPropertyChanged
+{
+ private string _title = "";
+ private string _description = "";
+ private string _price = "";
+ private string _condition = "Used";
+ private string _categoryKeyword = "";
+ private string _quantity = "1";
+ private string _photoPaths = "";
+ private BulkRowStatus _status = BulkRowStatus.Pending;
+ private string _statusMessage = "";
+
+ public string Title
+ {
+ get => _title;
+ set { _title = value; OnPropertyChanged(); }
+ }
+
+ public string Description
+ {
+ get => _description;
+ set { _description = value; OnPropertyChanged(); }
+ }
+
+ public string Price
+ {
+ get => _price;
+ set { _price = value; OnPropertyChanged(); }
+ }
+
+ public string Condition
+ {
+ get => _condition;
+ set { _condition = value; OnPropertyChanged(); }
+ }
+
+ public string CategoryKeyword
+ {
+ get => _categoryKeyword;
+ set { _categoryKeyword = value; OnPropertyChanged(); }
+ }
+
+ public string Quantity
+ {
+ get => _quantity;
+ set { _quantity = value; OnPropertyChanged(); }
+ }
+
+ public string PhotoPaths
+ {
+ get => _photoPaths;
+ set { _photoPaths = value; OnPropertyChanged(); }
+ }
+
+ public BulkRowStatus Status
+ {
+ get => _status;
+ set
+ {
+ _status = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(StatusIcon));
+ OnPropertyChanged(nameof(StatusBadge));
+ }
+ }
+
+ public string StatusMessage
+ {
+ get => _statusMessage;
+ set { _statusMessage = value; OnPropertyChanged(); }
+ }
+
+ public string StatusIcon => Status switch
+ {
+ BulkRowStatus.Pending => "⏳",
+ BulkRowStatus.Enhancing => "✨",
+ BulkRowStatus.Ready => "✅",
+ BulkRowStatus.Posting => "📤",
+ BulkRowStatus.Posted => "✅",
+ BulkRowStatus.Failed => "❌",
+ _ => ""
+ };
+
+ ///
+ /// String key used by XAML DataTriggers to apply colour-coded status badges.
+ /// Values: "Posted" | "Failed" | "Enhancing" | "Posting" | "Pending" | "Ready"
+ ///
+ public string StatusBadge => Status.ToString();
+
+ public ListingDraft ToListingDraft(string defaultPostcode)
+ {
+ var condition = Condition.ToLower() switch
+ {
+ "new" => ItemCondition.New,
+ "openbox" or "open box" => ItemCondition.OpenBox,
+ "refurbished" => ItemCondition.Refurbished,
+ "forparts" or "for parts" => ItemCondition.ForPartsOrNotWorking,
+ _ => ItemCondition.Used
+ };
+
+ return new ListingDraft
+ {
+ Title = Title,
+ Description = Description,
+ Price = decimal.TryParse(Price, out var p) ? p : 0,
+ Condition = condition,
+ Quantity = int.TryParse(Quantity, out var q) ? q : 1,
+ Postcode = defaultPostcode,
+ PhotoPaths = PhotoPaths.Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(x => x.Trim())
+ .Where(x => !string.IsNullOrEmpty(x))
+ .ToList()
+ };
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ protected void OnPropertyChanged([CallerMemberName] string? name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
diff --git a/EbayListingTool/Models/EbayToken.cs b/EbayListingTool/Models/EbayToken.cs
new file mode 100644
index 0000000..54e273e
--- /dev/null
+++ b/EbayListingTool/Models/EbayToken.cs
@@ -0,0 +1,16 @@
+namespace EbayListingTool.Models;
+
+public class EbayToken
+{
+ public string AccessToken { get; set; } = "";
+ public string RefreshToken { get; set; } = "";
+ public DateTime AccessTokenExpiry { get; set; }
+ public DateTime RefreshTokenExpiry { get; set; }
+ public string EbayUsername { get; set; } = "";
+
+ public bool IsAccessTokenValid => !string.IsNullOrEmpty(AccessToken)
+ && DateTime.UtcNow < AccessTokenExpiry.AddMinutes(-5);
+
+ public bool IsRefreshTokenValid => !string.IsNullOrEmpty(RefreshToken)
+ && DateTime.UtcNow < RefreshTokenExpiry;
+}
diff --git a/EbayListingTool/Models/ListingDraft.cs b/EbayListingTool/Models/ListingDraft.cs
new file mode 100644
index 0000000..440e873
--- /dev/null
+++ b/EbayListingTool/Models/ListingDraft.cs
@@ -0,0 +1,164 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace EbayListingTool.Models;
+
+public enum ItemCondition
+{
+ New,
+ OpenBox,
+ Refurbished,
+ Used,
+ ForPartsOrNotWorking
+}
+
+public enum ListingFormat
+{
+ FixedPrice,
+ Auction
+}
+
+public enum PostageOption
+{
+ RoyalMailFirstClass,
+ RoyalMailSecondClass,
+ RoyalMailTracked24,
+ RoyalMailTracked48,
+ CollectionOnly,
+ FreePostage
+}
+
+public class ListingDraft : INotifyPropertyChanged
+{
+ private string _title = "";
+ private string _description = "";
+ private decimal _price;
+ private int _quantity = 1;
+ private ItemCondition _condition = ItemCondition.Used;
+ private ListingFormat _format = ListingFormat.FixedPrice;
+ private PostageOption _postage = PostageOption.RoyalMailSecondClass;
+ private string _categoryId = "";
+ private string _categoryName = "";
+ private string _postcode = "";
+ private List _photoPaths = new();
+ private string? _ebayItemId;
+ private string? _ebayListingUrl;
+ private string _sku = Guid.NewGuid().ToString("N")[..12].ToUpper();
+ private bool _isPosting;
+ private string _statusMessage = "";
+
+ public string Title
+ {
+ get => _title;
+ set { _title = value; OnPropertyChanged(); OnPropertyChanged(nameof(TitleCharCount)); }
+ }
+
+ public string TitleCharCount => $"{_title.Length}/80";
+ public bool TitleTooLong => _title.Length > 80;
+
+ public string Description
+ {
+ get => _description;
+ set { _description = value; OnPropertyChanged(); }
+ }
+
+ public decimal Price
+ {
+ get => _price;
+ set { _price = value; OnPropertyChanged(); }
+ }
+
+ public int Quantity
+ {
+ get => _quantity;
+ set { _quantity = value; OnPropertyChanged(); }
+ }
+
+ public ItemCondition Condition
+ {
+ get => _condition;
+ set { _condition = value; OnPropertyChanged(); }
+ }
+
+ public ListingFormat Format
+ {
+ get => _format;
+ set { _format = value; OnPropertyChanged(); }
+ }
+
+ public PostageOption Postage
+ {
+ get => _postage;
+ set { _postage = value; OnPropertyChanged(); }
+ }
+
+ public string CategoryId
+ {
+ get => _categoryId;
+ set { _categoryId = value; OnPropertyChanged(); }
+ }
+
+ public string CategoryName
+ {
+ get => _categoryName;
+ set { _categoryName = value; OnPropertyChanged(); }
+ }
+
+ public string Postcode
+ {
+ get => _postcode;
+ set { _postcode = value; OnPropertyChanged(); }
+ }
+
+ public List PhotoPaths
+ {
+ get => _photoPaths;
+ set { _photoPaths = value; OnPropertyChanged(); }
+ }
+
+ public string? EbayItemId
+ {
+ get => _ebayItemId;
+ set { _ebayItemId = value; OnPropertyChanged(); OnPropertyChanged(nameof(IsPosted)); }
+ }
+
+ public string? EbayListingUrl
+ {
+ get => _ebayListingUrl;
+ set { _ebayListingUrl = value; OnPropertyChanged(); }
+ }
+
+ public string Sku
+ {
+ get => _sku;
+ set { _sku = value; OnPropertyChanged(); }
+ }
+
+ public bool IsPosting
+ {
+ get => _isPosting;
+ set { _isPosting = value; OnPropertyChanged(); }
+ }
+
+ public string StatusMessage
+ {
+ get => _statusMessage;
+ set { _statusMessage = value; OnPropertyChanged(); }
+ }
+
+ public bool IsPosted => !string.IsNullOrEmpty(_ebayItemId);
+
+ public string ConditionId => Condition switch
+ {
+ ItemCondition.New => "1000",
+ ItemCondition.OpenBox => "1500",
+ ItemCondition.Refurbished => "2500",
+ ItemCondition.Used => "3000",
+ ItemCondition.ForPartsOrNotWorking => "7000",
+ _ => "3000"
+ };
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ protected void OnPropertyChanged([CallerMemberName] string? name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
diff --git a/EbayListingTool/Models/PhotoAnalysisResult.cs b/EbayListingTool/Models/PhotoAnalysisResult.cs
new file mode 100644
index 0000000..4c3028a
--- /dev/null
+++ b/EbayListingTool/Models/PhotoAnalysisResult.cs
@@ -0,0 +1,23 @@
+namespace EbayListingTool.Models;
+
+public class PhotoAnalysisResult
+{
+ public string ItemName { get; set; } = "";
+ public string Brand { get; set; } = "";
+ public string Model { get; set; } = "";
+ public string ConditionNotes{ get; set; } = "";
+ public string Title { get; set; } = "";
+ public string Description { get; set; } = "";
+ public decimal PriceSuggested { get; set; }
+ public decimal PriceMin { get; set; }
+ public decimal PriceMax { get; set; }
+ public string CategoryKeyword { get; set; } = "";
+ public string IdentificationConfidence { get; set; } = "";
+ public string ConfidenceNotes { get; set; } = "";
+ public string PriceReasoning { get; set; } = "";
+
+ public string PriceRangeDisplay =>
+ PriceMin > 0 && PriceMax > 0
+ ? $"£{PriceMin:F2} – £{PriceMax:F2} (suggested £{PriceSuggested:F2})"
+ : PriceSuggested > 0 ? $"£{PriceSuggested:F2}" : "";
+}
diff --git a/EbayListingTool/Models/SavedListing.cs b/EbayListingTool/Models/SavedListing.cs
new file mode 100644
index 0000000..a269c09
--- /dev/null
+++ b/EbayListingTool/Models/SavedListing.cs
@@ -0,0 +1,22 @@
+namespace EbayListingTool.Models;
+
+public class SavedListing
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public DateTime SavedAt { get; set; } = DateTime.UtcNow; // Q4: store UTC, display local
+ public string Title { get; set; } = "";
+ public string Description { get; set; } = "";
+ public decimal Price { get; set; }
+ public string Category { get; set; } = "";
+ public string ConditionNotes { get; set; } = "";
+ public string ExportFolder { get; set; } = "";
+
+ /// Absolute paths to photos inside ExportFolder.
+ public List PhotoPaths { get; set; } = new();
+
+ public string FirstPhotoPath => PhotoPaths.Count > 0 ? PhotoPaths[0] : "";
+
+ public string PriceDisplay => Price > 0 ? $"£{Price:F2}" : "—";
+
+ public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm");
+}
diff --git a/EbayListingTool/Services/AiAssistantService.cs b/EbayListingTool/Services/AiAssistantService.cs
new file mode 100644
index 0000000..aa0466d
--- /dev/null
+++ b/EbayListingTool/Services/AiAssistantService.cs
@@ -0,0 +1,315 @@
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using EbayListingTool.Models;
+using Microsoft.Extensions.Configuration;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace EbayListingTool.Services;
+
+///
+/// Calls Claude via OpenRouter (https://openrouter.ai) using the OpenAI-compatible
+/// chat completions endpoint. Sign up at openrouter.ai to get an API key.
+///
+public class AiAssistantService
+{
+ private readonly string _apiKey;
+ private readonly string _model;
+ private static readonly HttpClient _http = new();
+
+ private const string ApiUrl = "https://openrouter.ai/api/v1/chat/completions";
+
+ private const string SystemPrompt =
+ "You are an expert eBay UK seller assistant. You help write compelling, accurate, " +
+ "keyword-rich listings for eBay.co.uk. Always use British English. " +
+ "Be honest about item condition. Keep titles under 80 characters. " +
+ "Descriptions should be clear and informative for buyers.";
+
+ public AiAssistantService(IConfiguration config)
+ {
+ _apiKey = config["OpenRouter:ApiKey"] ?? "";
+ _model = config["OpenRouter:Model"] ?? "anthropic/claude-sonnet-4-5";
+ }
+
+ public async Task GenerateTitleAsync(string productName, string condition, string notes = "")
+ {
+ var prompt = $"Write a concise eBay UK listing title (MAXIMUM 80 characters, no more) for this item:\n" +
+ $"Item: {productName}\n" +
+ $"Condition: {condition}\n" +
+ (string.IsNullOrWhiteSpace(notes) ? "" : $"Notes: {notes}\n") +
+ $"\nReturn ONLY the title, nothing else. No quotes, no explanation.";
+
+ return await CallAsync(prompt);
+ }
+
+ public async Task WriteDescriptionAsync(string title, string condition, string notes = "")
+ {
+ var prompt = $"Write a clear, honest eBay UK product description for this listing:\n" +
+ $"Title: {title}\n" +
+ $"Condition: {condition}\n" +
+ (string.IsNullOrWhiteSpace(notes) ? "" : $"Seller notes: {notes}\n") +
+ $"\nInclude:\n" +
+ $"- What the item is and what's included\n" +
+ $"- Honest condition notes\n" +
+ $"- A note about postage (dispatched within 1-2 working days)\n" +
+ $"- Payment via eBay only\n\n" +
+ $"Use plain text with line breaks. No HTML. UK English. Keep it friendly and professional.\n" +
+ $"Return ONLY the description text.";
+
+ return await CallAsync(prompt);
+ }
+
+ public async Task SuggestPriceAsync(string title, string condition, IEnumerable? soldPrices = null)
+ {
+ string priceContext = "";
+ if (soldPrices != null && soldPrices.Any())
+ {
+ var prices = soldPrices.Select(p => $"£{p:F2}");
+ priceContext = $"\nRecent eBay UK sold prices for similar items: {string.Join(", ", prices)}";
+ }
+
+ var prompt = $"Suggest a competitive Buy It Now price in GBP for this eBay UK listing:\n" +
+ $"Item: {title}\n" +
+ $"Condition: {condition}" +
+ priceContext +
+ $"\n\nRespond in this exact format:\n" +
+ $"PRICE: [number only, e.g. 29.99]\n" +
+ $"REASON: [one sentence explaining the price]";
+
+ return await CallAsync(prompt);
+ }
+
+ public async Task EnhanceListingAsync(BulkImportRow row)
+ {
+ var needsTitle = string.IsNullOrWhiteSpace(row.Title);
+ var needsDescription = string.IsNullOrWhiteSpace(row.Description);
+ var needsPrice = string.IsNullOrWhiteSpace(row.Price) || row.Price == "0";
+
+ if (!needsTitle && !needsDescription && !needsPrice)
+ return "No changes needed.";
+
+ var prompt = new StringBuilder();
+ prompt.AppendLine("Fill in the missing fields for this eBay UK listing. Return valid JSON only.");
+ prompt.AppendLine($"Item info: Title={row.Title}, Condition={row.Condition}, Category={row.CategoryKeyword}");
+ prompt.AppendLine();
+ prompt.AppendLine("Return a JSON object with these fields:");
+ prompt.AppendLine("{");
+ if (needsTitle) prompt.AppendLine(" \"title\": \"[max 80 chars, keyword-rich eBay title]\",");
+ if (needsDescription) prompt.AppendLine(" \"description\": \"[clear plain-text description for eBay UK]\",");
+ if (needsPrice) prompt.AppendLine(" \"price\": \"[suggested price as a number, e.g. 19.99]\"");
+ prompt.AppendLine("}");
+ prompt.AppendLine("Return ONLY the JSON object, no other text.");
+
+ var json = await CallAsync(prompt.ToString());
+
+ try
+ {
+ JObject obj;
+ try
+ {
+ obj = JObject.Parse(json.Trim());
+ }
+ catch (JsonReaderException)
+ {
+ // Claude sometimes wraps JSON in ```json...``` fences
+ var match = System.Text.RegularExpressions.Regex.Match(
+ json, @"```(?:json)?\s*(\{[\s\S]*?\})\s*```");
+ var candidate = match.Success ? match.Groups[1].Value : json.Trim();
+ obj = JObject.Parse(candidate);
+ }
+
+ if (needsTitle && obj["title"] != null)
+ row.Title = obj["title"]!.ToString();
+ if (needsDescription && obj["description"] != null)
+ row.Description = obj["description"]!.ToString();
+ if (needsPrice && obj["price"] != null)
+ row.Price = obj["price"]!.ToString();
+
+ return "Enhanced successfully.";
+ }
+ catch
+ {
+ return $"AI returned unexpected format: {json}";
+ }
+ }
+
+ /// Convenience wrapper — analyses a single photo.
+ public Task AnalyseItemFromPhotoAsync(string imagePath)
+ => AnalyseItemFromPhotosAsync(new[] { imagePath });
+
+ ///
+ /// Analyses one or more photos of the same item (up to 4) using Claude vision.
+ /// All images are included in a single request so the model can use every angle.
+ ///
+ public async Task AnalyseItemFromPhotosAsync(IEnumerable imagePaths)
+ {
+ var paths = imagePaths.Take(4).ToList();
+ if (paths.Count == 0)
+ throw new ArgumentException("At least one image path must be provided.", nameof(imagePaths));
+
+ // Build base64 data URLs for every image
+ const long MaxImageBytes = 8 * 1024 * 1024; // E4: reject before base64 to avoid OOM
+ var dataUrls = new List(paths.Count);
+ foreach (var path in paths)
+ {
+ var fileInfo = new FileInfo(path);
+ if (fileInfo.Length > MaxImageBytes)
+ throw new InvalidOperationException(
+ $"Photo \"{Path.GetFileName(path)}\" is {fileInfo.Length / 1_048_576.0:F0} MB — please use images under 8 MB.");
+
+ var imageBytes = await File.ReadAllBytesAsync(path);
+ var base64 = Convert.ToBase64String(imageBytes);
+ var ext = Path.GetExtension(path).TrimStart('.').ToLower();
+ var mimeType = ext switch { "jpg" or "jpeg" => "image/jpeg", "png" => "image/png",
+ "gif" => "image/gif", "webp" => "image/webp", _ => "image/jpeg" };
+ dataUrls.Add($"data:{mimeType};base64,{base64}");
+ }
+
+ var multiPhoto = dataUrls.Count > 1;
+ var photoPrompt = multiPhoto
+ ? "These are multiple photos of the same item from different angles. Use all photos together to identify the item accurately."
+ : "Analyse this photo of an item.";
+
+ var prompt =
+ $"You are an expert eBay UK seller. {photoPrompt} Return a JSON object " +
+ "with everything needed to create an eBay UK listing.\n\n" +
+ "Return ONLY valid JSON — no markdown, no explanation:\n" +
+ "{\n" +
+ " \"item_name\": \"full descriptive name of the item\",\n" +
+ " \"brand\": \"brand name or empty string if unknown\",\n" +
+ " \"model\": \"model name/number or empty string if unknown\",\n" +
+ " \"condition_notes\": \"honest assessment of visible condition from the photo(s)\",\n" +
+ " \"title\": \"eBay UK listing title, max 80 chars, keyword-rich\",\n" +
+ " \"description\": \"full plain-text eBay UK description including what it is, condition, " +
+ "what's likely included, postage note (dispatched within 1-2 working days)\",\n" +
+ " \"price_suggested\": 0.00,\n" +
+ " \"price_min\": 0.00,\n" +
+ " \"price_max\": 0.00,\n" +
+ " \"price_reasoning\": \"one sentence why this price\",\n" +
+ " \"category_keyword\": \"best eBay category keyword to search\",\n" +
+ " \"identification_confidence\": \"High, Medium, or Low\",\n" +
+ " \"confidence_notes\": \"one sentence explaining confidence level, e.g. brand clearly visible on label\"\n" +
+ "}\n\n" +
+ "For prices: research realistic eBay UK sold prices in your knowledge. " +
+ "price_suggested should be a good Buy It Now price. Use GBP numbers only (no £ symbol).";
+
+ var json = await CallWithVisionAsync(dataUrls, 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());
+ }
+
+ return new PhotoAnalysisResult
+ {
+ ItemName = obj["item_name"]?.ToString() ?? "",
+ Brand = obj["brand"]?.ToString() ?? "",
+ Model = obj["model"]?.ToString() ?? "",
+ ConditionNotes = obj["condition_notes"]?.ToString() ?? "",
+ Title = obj["title"]?.ToString() ?? "",
+ Description = obj["description"]?.ToString() ?? "",
+ PriceSuggested = obj["price_suggested"]?.Value() ?? 0,
+ PriceMin = obj["price_min"]?.Value() ?? 0,
+ PriceMax = obj["price_max"]?.Value() ?? 0,
+ PriceReasoning = obj["price_reasoning"]?.ToString() ?? "",
+ CategoryKeyword = obj["category_keyword"]?.ToString() ?? "",
+ IdentificationConfidence = obj["identification_confidence"]?.ToString() ?? "",
+ ConfidenceNotes = obj["confidence_notes"]?.ToString() ?? ""
+ };
+ }
+ catch (Exception ex)
+ {
+ // E5: don't expose raw API response (may contain sensitive data / confuse users)
+ throw new InvalidOperationException($"Could not parse AI response: {ex.Message}");
+ }
+ }
+
+ private async Task CallWithVisionAsync(IEnumerable imageDataUrls, string textPrompt)
+ {
+ if (string.IsNullOrEmpty(_apiKey))
+ throw new InvalidOperationException("OpenRouter API key not configured in appsettings.json.");
+
+ // Build content array: one image_url block per image, then a single text block
+ var contentParts = imageDataUrls
+ .Select(url => (object)new { type = "image_url", image_url = new { url } })
+ .Append(new { type = "text", text = textPrompt })
+ .ToArray();
+
+ // Vision request: content is an array of image + text parts
+ var requestBody = new
+ {
+ model = _model,
+ max_tokens = 1500,
+ messages = new object[]
+ {
+ new { role = "system", content = SystemPrompt },
+ new
+ {
+ role = "user",
+ content = contentParts
+ }
+ }
+ };
+
+ var json = JsonConvert.SerializeObject(requestBody);
+ using var request = new HttpRequestMessage(HttpMethod.Post, ApiUrl);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
+ request.Headers.Add("HTTP-Referer", "https://github.com/ebay-listing-tool");
+ request.Headers.Add("X-Title", "eBay Listing Tool");
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _http.SendAsync(request);
+ var responseJson = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"OpenRouter error ({(int)response.StatusCode}): {responseJson}");
+
+ var obj = JObject.Parse(responseJson);
+ return obj["choices"]?[0]?["message"]?["content"]?.ToString()
+ ?? throw new InvalidOperationException("Unexpected response from OpenRouter.");
+ }
+
+ private async Task CallAsync(string userMessage)
+ {
+ if (string.IsNullOrEmpty(_apiKey))
+ throw new InvalidOperationException(
+ "OpenRouter API key not configured.\n\n" +
+ "1. Sign up at https://openrouter.ai\n" +
+ "2. Create an API key\n" +
+ "3. Add it to appsettings.json under OpenRouter:ApiKey");
+
+ var requestBody = new
+ {
+ model = _model,
+ max_tokens = 1024,
+ messages = new[]
+ {
+ new { role = "system", content = SystemPrompt },
+ new { role = "user", content = userMessage }
+ }
+ };
+
+ var json = JsonConvert.SerializeObject(requestBody);
+ using var request = new HttpRequestMessage(HttpMethod.Post, ApiUrl);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
+ request.Headers.Add("HTTP-Referer", "https://github.com/ebay-listing-tool");
+ request.Headers.Add("X-Title", "eBay Listing Tool");
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _http.SendAsync(request);
+ var responseJson = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"OpenRouter API error ({(int)response.StatusCode}): {responseJson}");
+
+ var obj = JObject.Parse(responseJson);
+ return obj["choices"]?[0]?["message"]?["content"]?.ToString()
+ ?? throw new InvalidOperationException("Unexpected response format from OpenRouter.");
+ }
+}
diff --git a/EbayListingTool/Services/BulkImportService.cs b/EbayListingTool/Services/BulkImportService.cs
new file mode 100644
index 0000000..30c6ba5
--- /dev/null
+++ b/EbayListingTool/Services/BulkImportService.cs
@@ -0,0 +1,100 @@
+using System.Globalization;
+using ClosedXML.Excel;
+using CsvHelper;
+using CsvHelper.Configuration;
+using EbayListingTool.Models;
+
+namespace EbayListingTool.Services;
+
+public class BulkImportService
+{
+ public List ImportFile(string filePath)
+ {
+ var ext = Path.GetExtension(filePath).ToLower();
+ return ext switch
+ {
+ ".csv" => ImportCsv(filePath),
+ ".xlsx" or ".xls" => ImportExcel(filePath),
+ _ => throw new NotSupportedException($"File type '{ext}' is not supported. Use .csv or .xlsx")
+ };
+ }
+
+ private List ImportCsv(string filePath)
+ {
+ var config = new CsvConfiguration(CultureInfo.InvariantCulture)
+ {
+ HasHeaderRecord = true,
+ MissingFieldFound = null,
+ HeaderValidated = null
+ };
+
+ using var reader = new StreamReader(filePath);
+ using var csv = new CsvReader(reader, config);
+
+ var rows = new List();
+ csv.Read();
+ csv.ReadHeader();
+
+ while (csv.Read())
+ {
+ rows.Add(new BulkImportRow
+ {
+ Title = csv.TryGetField("Title", out string? t) ? t ?? "" : "",
+ Description = csv.TryGetField("Description", out string? d) ? d ?? "" : "",
+ Price = csv.TryGetField("Price", out string? p) ? p ?? "" : "",
+ Condition = csv.TryGetField("Condition", out string? c) ? c ?? "Used" : "Used",
+ CategoryKeyword = csv.TryGetField("CategoryKeyword", out string? k) ? k ?? "" : "",
+ Quantity = csv.TryGetField("Quantity", out string? q) ? q ?? "1" : "1",
+ PhotoPaths = csv.TryGetField("PhotoPaths", out string? ph) ? ph ?? "" : ""
+ });
+ }
+
+ return rows;
+ }
+
+ private List ImportExcel(string filePath)
+ {
+ var rows = new List();
+
+ using var workbook = new XLWorkbook(filePath);
+ var ws = workbook.Worksheets.First();
+
+ // Find header row (row 1)
+ var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var headerRow = ws.Row(1);
+ foreach (var cell in headerRow.CellsUsed())
+ headers[cell.GetString()] = cell.Address.ColumnNumber;
+
+ string GetValue(IXLRow row, string colName)
+ {
+ if (!headers.TryGetValue(colName, out var col)) return "";
+ return row.Cell(col).GetString().Trim();
+ }
+
+ int lastRow = ws.LastRowUsed()?.RowNumber() ?? 1;
+ for (int i = 2; i <= lastRow; i++)
+ {
+ var row = ws.Row(i);
+ if (row.IsEmpty()) continue;
+
+ rows.Add(new BulkImportRow
+ {
+ Title = GetValue(row, "Title"),
+ Description = GetValue(row, "Description"),
+ Price = GetValue(row, "Price"),
+ Condition = GetValue(row, "Condition").OrDefault("Used"),
+ CategoryKeyword = GetValue(row, "CategoryKeyword"),
+ Quantity = GetValue(row, "Quantity").OrDefault("1"),
+ PhotoPaths = GetValue(row, "PhotoPaths")
+ });
+ }
+
+ return rows;
+ }
+}
+
+internal static class StringExtensions
+{
+ public static string OrDefault(this string value, string defaultValue)
+ => string.IsNullOrWhiteSpace(value) ? defaultValue : value;
+}
diff --git a/EbayListingTool/Services/EbayAuthService.cs b/EbayListingTool/Services/EbayAuthService.cs
new file mode 100644
index 0000000..f0167b2
--- /dev/null
+++ b/EbayListingTool/Services/EbayAuthService.cs
@@ -0,0 +1,221 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using EbayListingTool.Models;
+using Microsoft.Extensions.Configuration;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace EbayListingTool.Services;
+
+public class EbayAuthService
+{
+ private readonly EbaySettings _settings;
+ private readonly string _tokenPath;
+ private EbayToken? _token;
+
+ private static readonly string[] Scopes =
+ [
+ "https://api.ebay.com/oauth/api_scope",
+ "https://api.ebay.com/oauth/api_scope/sell.inventory",
+ "https://api.ebay.com/oauth/api_scope/sell.listing",
+ "https://api.ebay.com/oauth/api_scope/sell.fulfillment",
+ "https://api.ebay.com/oauth/api_scope/sell.account"
+ ];
+
+ public EbayAuthService(IConfiguration config)
+ {
+ _settings = config.GetSection("Ebay").Get() ?? new EbaySettings();
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var dir = Path.Combine(appData, "EbayListingTool");
+ Directory.CreateDirectory(dir);
+ _tokenPath = Path.Combine(dir, "tokens.json");
+ }
+
+ public string? ConnectedUsername => _token?.EbayUsername;
+ public bool IsConnected => _token?.IsAccessTokenValid == true || _token?.IsRefreshTokenValid == true;
+
+ public string BaseUrl => _settings.Sandbox
+ ? "https://api.sandbox.ebay.com"
+ : "https://api.ebay.com";
+
+ public async Task GetValidAccessTokenAsync()
+ {
+ _token ??= LoadToken();
+
+ if (_token == null)
+ throw new InvalidOperationException("Not authenticated. Please connect to eBay first.");
+
+ if (_token.IsAccessTokenValid)
+ return _token.AccessToken;
+
+ if (_token.IsRefreshTokenValid)
+ {
+ await RefreshAccessTokenAsync();
+ return _token.AccessToken;
+ }
+
+ throw new InvalidOperationException("eBay session expired. Please reconnect.");
+ }
+
+ public async Task LoginAsync()
+ {
+ var redirectUri = $"http://localhost:{_settings.RedirectPort}/";
+ var scopeString = Uri.EscapeDataString(string.Join(" ", Scopes));
+ var authBase = _settings.Sandbox
+ ? "https://auth.sandbox.ebay.com/oauth2/authorize"
+ : "https://auth.ebay.com/oauth2/authorize";
+
+ var authUrl = $"{authBase}?client_id={_settings.ClientId}" +
+ $"&redirect_uri={Uri.EscapeDataString(_settings.RuName)}" +
+ $"&response_type=code&scope={scopeString}";
+
+ // Start local listener before opening browser
+ using var listener = new HttpListener();
+ listener.Prefixes.Add(redirectUri);
+ listener.Start();
+
+ // Open browser
+ Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });
+
+ // Wait for redirect with code (60s timeout)
+ var contextTask = listener.GetContextAsync();
+ if (await Task.WhenAny(contextTask, Task.Delay(TimeSpan.FromSeconds(60))) != contextTask)
+ {
+ listener.Stop();
+ throw new TimeoutException("eBay login timed out. Please try again.");
+ }
+
+ var context = await contextTask;
+ var code = context.Request.QueryString["code"]
+ ?? throw new InvalidOperationException("No authorisation code received from eBay.");
+
+ // Send OK page to browser
+ var responseHtml = "Connected! You can close this tab.
";
+ var responseBytes = Encoding.UTF8.GetBytes(responseHtml);
+ context.Response.ContentType = "text/html";
+ context.Response.ContentLength64 = responseBytes.Length;
+ await context.Response.OutputStream.WriteAsync(responseBytes);
+ context.Response.Close();
+ listener.Stop();
+
+ await ExchangeCodeForTokenAsync(code);
+ return _token!.EbayUsername;
+ }
+
+ private async Task ExchangeCodeForTokenAsync(string code)
+ {
+ 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"] = "authorization_code",
+ ["code"] = code,
+ ["redirect_uri"] = _settings.RuName
+ });
+
+ var response = await http.PostAsync(tokenUrl, body);
+ var json = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"Token exchange failed: {json}");
+
+ var obj = JObject.Parse(json);
+ _token = new EbayToken
+ {
+ AccessToken = obj["access_token"]!.ToString(),
+ RefreshToken = obj["refresh_token"]!.ToString(),
+ AccessTokenExpiry = DateTime.UtcNow.AddSeconds(obj["expires_in"]!.Value()),
+ RefreshTokenExpiry = DateTime.UtcNow.AddSeconds(obj["refresh_token_expires_in"]!.Value()),
+ };
+
+ // Fetch username
+ _token.EbayUsername = await FetchUsernameAsync(_token.AccessToken);
+ SaveToken(_token);
+ }
+
+ private async Task RefreshAccessTokenAsync()
+ {
+ 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"] = "refresh_token",
+ ["refresh_token"] = _token!.RefreshToken,
+ ["scope"] = string.Join(" ", Scopes)
+ });
+
+ var response = await http.PostAsync(tokenUrl, body);
+ var json = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"Token refresh failed: {json}");
+
+ var obj = JObject.Parse(json);
+ _token.AccessToken = obj["access_token"]!.ToString();
+ _token.AccessTokenExpiry = DateTime.UtcNow.AddSeconds(obj["expires_in"]!.Value());
+ SaveToken(_token);
+ }
+
+ private async Task FetchUsernameAsync(string accessToken)
+ {
+ 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);
+ var obj = JObject.Parse(json);
+ return obj["username"]?.ToString() ?? "Unknown";
+ }
+ catch
+ {
+ return "Connected";
+ }
+ }
+
+ public void Disconnect()
+ {
+ _token = null;
+ if (File.Exists(_tokenPath))
+ File.Delete(_tokenPath);
+ }
+
+ public void TryLoadSavedToken()
+ {
+ _token = LoadToken();
+ }
+
+ private EbayToken? LoadToken()
+ {
+ if (!File.Exists(_tokenPath)) return null;
+ try
+ {
+ var json = File.ReadAllText(_tokenPath);
+ return JsonConvert.DeserializeObject(json);
+ }
+ catch { return null; }
+ }
+
+ private void SaveToken(EbayToken token)
+ {
+ File.WriteAllText(_tokenPath, JsonConvert.SerializeObject(token, Formatting.Indented));
+ }
+}
diff --git a/EbayListingTool/Services/EbayCategoryService.cs b/EbayListingTool/Services/EbayCategoryService.cs
new file mode 100644
index 0000000..18ebcdb
--- /dev/null
+++ b/EbayListingTool/Services/EbayCategoryService.cs
@@ -0,0 +1,76 @@
+using System.Net.Http.Headers;
+using Newtonsoft.Json.Linq;
+
+namespace EbayListingTool.Services;
+
+public class CategorySuggestion
+{
+ public string CategoryId { get; set; } = "";
+ public string CategoryName { get; set; } = "";
+ public string CategoryPath { get; set; } = "";
+}
+
+public class EbayCategoryService
+{
+ private readonly EbayAuthService _auth;
+
+ public EbayCategoryService(EbayAuthService auth)
+ {
+ _auth = auth;
+ }
+
+ public async Task> GetCategorySuggestionsAsync(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query) || query.Length < 3)
+ return new List();
+
+ try
+ {
+ var token = await _auth.GetValidAccessTokenAsync();
+ using var http = new HttpClient();
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
+
+ var url = $"{_auth.BaseUrl}/commerce/taxonomy/v1/category_tree/3/get_category_suggestions" +
+ $"?q={Uri.EscapeDataString(query)}";
+
+ var json = await http.GetStringAsync(url);
+ var obj = JObject.Parse(json);
+
+ var results = new List();
+ var suggestions = obj["categorySuggestions"] as JArray;
+ if (suggestions == null) return results;
+
+ foreach (var s in suggestions.Take(8))
+ {
+ var cat = s["category"];
+ if (cat == null) continue;
+
+ // Build breadcrumb path
+ var ancestors = s["categoryTreeNodeAncestors"] as JArray;
+ var path = ancestors != null
+ ? string.Join(" > ", ancestors.Reverse().Select(a => a["categoryName"]?.ToString() ?? "")) + " > " + cat["categoryName"]
+ : cat["categoryName"]?.ToString() ?? "";
+
+ results.Add(new CategorySuggestion
+ {
+ CategoryId = cat["categoryId"]?.ToString() ?? "",
+ CategoryName = cat["categoryName"]?.ToString() ?? "",
+ CategoryPath = path
+ });
+ }
+
+ return results;
+ }
+ catch (InvalidOperationException)
+ {
+ return new List();
+ }
+ }
+
+ public async Task GetCategoryIdByKeywordAsync(string keyword)
+ {
+ var suggestions = await GetCategorySuggestionsAsync(keyword);
+ return suggestions.FirstOrDefault()?.CategoryId;
+ }
+}
diff --git a/EbayListingTool/Services/EbayListingService.cs b/EbayListingTool/Services/EbayListingService.cs
new file mode 100644
index 0000000..30bf643
--- /dev/null
+++ b/EbayListingTool/Services/EbayListingService.cs
@@ -0,0 +1,274 @@
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using EbayListingTool.Models;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace EbayListingTool.Services;
+
+public class EbayListingService
+{
+ private readonly EbayAuthService _auth;
+ private readonly EbayCategoryService _categoryService;
+
+ public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
+ {
+ _auth = auth;
+ _categoryService = categoryService;
+ }
+
+ public async Task PostListingAsync(ListingDraft draft)
+ {
+ var token = await _auth.GetValidAccessTokenAsync();
+
+ // 1. Upload photos and get URLs
+ var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
+
+ // 2. Resolve category if not set
+ if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName))
+ {
+ draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName)
+ ?? throw new InvalidOperationException($"Could not find category for: {draft.CategoryName}");
+ }
+
+ if (string.IsNullOrEmpty(draft.CategoryId))
+ throw new InvalidOperationException("Please select a category before posting.");
+
+ // 3. Create inventory item
+ await CreateInventoryItemAsync(draft, imageUrls, token);
+
+ // 4. Create offer
+ var offerId = await CreateOfferAsync(draft, token);
+
+ // 5. Publish offer → get item ID
+ var itemId = await PublishOfferAsync(offerId, token);
+
+ draft.EbayItemId = itemId;
+ var domain = _auth.BaseUrl.Contains("sandbox") ? "sandbox.ebay.co.uk" : "ebay.co.uk";
+ draft.EbayListingUrl = $"https://www.{domain}/itm/{itemId}";
+
+ return draft.EbayListingUrl;
+ }
+
+ private async Task CreateInventoryItemAsync(ListingDraft draft, List imageUrls, string token)
+ {
+ using var http = BuildClient(token);
+
+ var aspects = new Dictionary>();
+
+ var inventoryItem = new
+ {
+ availability = new
+ {
+ shipToLocationAvailability = new
+ {
+ quantity = draft.Quantity
+ }
+ },
+ condition = draft.ConditionId,
+ conditionDescription = draft.Condition == ItemCondition.Used ? "Used - see photos" : null,
+ description = draft.Description,
+ title = draft.Title,
+ product = new
+ {
+ title = draft.Title,
+ description = draft.Description,
+ imageUrls = imageUrls.Count > 0 ? imageUrls : null,
+ aspects = aspects.Count > 0 ? aspects : null
+ }
+ };
+
+ var json = JsonConvert.SerializeObject(inventoryItem, new JsonSerializerSettings
+ {
+ NullValueHandling = NullValueHandling.Ignore
+ });
+
+ var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
+ var request = new HttpRequestMessage(HttpMethod.Put, url)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ request.Content.Headers.Add("Content-Language", "en-GB");
+
+ var response = await http.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ {
+ var error = await response.Content.ReadAsStringAsync();
+ throw new HttpRequestException($"Failed to create inventory item: {error}");
+ }
+ }
+
+ private async Task CreateOfferAsync(ListingDraft draft, string token)
+ {
+ using var http = BuildClient(token);
+
+ var listingPolicies = BuildListingPolicies(draft);
+
+ var offer = new
+ {
+ sku = draft.Sku,
+ marketplaceId = "EBAY_GB",
+ format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE",
+ availableQuantity = draft.Quantity,
+ categoryId = draft.CategoryId,
+ listingDescription = draft.Description,
+ listingPolicies,
+ pricingSummary = new
+ {
+ price = new { value = draft.Price.ToString("F2"), currency = "GBP" }
+ },
+ merchantLocationKey = "home",
+ tax = new { vatPercentage = 0, applyTax = false }
+ };
+
+ var json = JsonConvert.SerializeObject(offer, new JsonSerializerSettings
+ {
+ NullValueHandling = NullValueHandling.Ignore
+ });
+
+ var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer";
+ var response = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json"));
+ var responseJson = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"Failed to create offer: {responseJson}");
+
+ var obj = JObject.Parse(responseJson);
+ return obj["offerId"]?.ToString()
+ ?? throw new InvalidOperationException("No offerId in create offer response.");
+ }
+
+ private async Task PublishOfferAsync(string offerId, string token)
+ {
+ using var http = BuildClient(token);
+ var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish";
+ var response = await http.PostAsync(url, new StringContent("{}", Encoding.UTF8, "application/json"));
+ var responseJson = await response.Content.ReadAsStringAsync();
+
+ if (!response.IsSuccessStatusCode)
+ throw new HttpRequestException($"Failed to publish offer: {responseJson}");
+
+ var obj = JObject.Parse(responseJson);
+ return obj["listingId"]?.ToString()
+ ?? throw new InvalidOperationException("No listingId in publish response.");
+ }
+
+ private async Task> UploadPhotosAsync(List photoPaths, string token)
+ {
+ var urls = new List();
+ if (photoPaths.Count == 0) return urls;
+
+ // Use Trading API UploadSiteHostedPictures for each photo
+ var tradingBase = _auth.BaseUrl.Contains("sandbox")
+ ? "https://api.sandbox.ebay.com/ws/api.dll"
+ : "https://api.ebay.com/ws/api.dll";
+
+ foreach (var path in photoPaths.Take(12))
+ {
+ if (!File.Exists(path)) continue;
+
+ try
+ {
+ var url = await UploadSinglePhotoAsync(path, tradingBase, token);
+ if (!string.IsNullOrEmpty(url))
+ urls.Add(url);
+ }
+ catch
+ {
+ // Skip failed photo uploads, don't abort the whole listing
+ }
+ }
+
+ return urls;
+ }
+
+ private async Task UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
+ {
+ var fileBytes = await File.ReadAllBytesAsync(filePath);
+ var base64 = Convert.ToBase64String(fileBytes);
+ var ext = Path.GetExtension(filePath).TrimStart('.').ToUpper();
+
+ var soapBody = $"""
+
+
+
+ {token}
+
+ {Path.GetFileNameWithoutExtension(filePath)}
+ Supersize
+ https://example.com/placeholder.jpg
+
+ """;
+
+ // For binary upload, use multipart
+ using var http = new HttpClient();
+ http.DefaultRequestHeaders.Add("X-EBAY-API-SITEID", "3");
+ http.DefaultRequestHeaders.Add("X-EBAY-API-COMPATIBILITY-LEVEL", "967");
+ http.DefaultRequestHeaders.Add("X-EBAY-API-CALL-NAME", "UploadSiteHostedPictures");
+ http.DefaultRequestHeaders.Add("X-EBAY-API-IAF-TOKEN", token);
+
+ using var content = new MultipartFormDataContent();
+ content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
+ var imageContent = new ByteArrayContent(fileBytes);
+ imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext.ToLower()}");
+ content.Add(imageContent, "dummy", Path.GetFileName(filePath));
+
+ var response = await http.PostAsync(tradingUrl, content);
+ var responseXml = await response.Content.ReadAsStringAsync();
+
+ // Parse URL from XML response
+ var match = System.Text.RegularExpressions.Regex.Match(
+ responseXml, @"(.*?)");
+ return match.Success ? match.Groups[1].Value : null;
+ }
+
+ private JObject BuildListingPolicies(ListingDraft draft)
+ {
+ var (serviceCode, costValue) = draft.Postage switch
+ {
+ PostageOption.RoyalMailFirstClass => ("UK_RoyalMailFirstClass", "1.50"),
+ PostageOption.RoyalMailSecondClass => ("UK_RoyalMailSecondClass", "1.20"),
+ PostageOption.RoyalMailTracked24 => ("UK_RoyalMailTracked24", "2.95"),
+ PostageOption.RoyalMailTracked48 => ("UK_RoyalMailTracked48", "2.50"),
+ PostageOption.FreePostage => ("UK_RoyalMailSecondClass", "0.00"),
+ _ => ("UK_CollectionInPerson", "0.00")
+ };
+
+ return new JObject
+ {
+ ["shippingPolicyName"] = "Default",
+ ["paymentPolicyName"] = "Default",
+ ["returnPolicyName"] = "Default",
+ ["shippingCostType"] = "FLAT_RATE",
+ ["shippingOptions"] = new JArray
+ {
+ new JObject
+ {
+ ["optionType"] = "DOMESTIC",
+ ["costType"] = "FLAT_RATE",
+ ["shippingServices"] = new JArray
+ {
+ new JObject
+ {
+ ["shippingServiceCode"] = serviceCode,
+ ["shippingCost"] = new JObject
+ {
+ ["value"] = costValue,
+ ["currency"] = "GBP"
+ }
+ }
+ }
+ }
+ }
+ };
+ }
+
+ private HttpClient BuildClient(string token)
+ {
+ var http = new HttpClient();
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
+ return http;
+ }
+}
diff --git a/EbayListingTool/Services/SavedListingsService.cs b/EbayListingTool/Services/SavedListingsService.cs
new file mode 100644
index 0000000..ff3605c
--- /dev/null
+++ b/EbayListingTool/Services/SavedListingsService.cs
@@ -0,0 +1,167 @@
+using EbayListingTool.Models;
+using Newtonsoft.Json;
+
+namespace EbayListingTool.Services;
+
+///
+/// Persists saved listings to %APPDATA%\EbayListingTool\saved_listings.json
+/// and exports each listing to its own subfolder under
+/// %APPDATA%\EbayListingTool\Exports\{ItemName}\
+///
+public class SavedListingsService
+{
+ private static readonly string AppDataDir =
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "EbayListingTool");
+
+ private static readonly string ExportsDir = Path.Combine(AppDataDir, "Exports");
+ private static readonly string IndexFile = Path.Combine(AppDataDir, "saved_listings.json");
+
+ private List _listings = new();
+
+ public IReadOnlyList Listings => _listings;
+
+ public SavedListingsService()
+ {
+ Directory.CreateDirectory(AppDataDir);
+ Directory.CreateDirectory(ExportsDir);
+ Load();
+ }
+
+ ///
+ /// Saves the listing: copies photos to an export folder, writes a text file,
+ /// appends to the JSON index, and returns (listing, skippedPhotoCount).
+ ///
+ public (SavedListing Listing, int SkippedPhotos) Save(
+ string title, string description, decimal price,
+ string category, string conditionNotes,
+ IEnumerable sourcePaths)
+ {
+ var safeName = MakeSafeFilename(title);
+ var exportDir = UniqueDir(Path.Combine(ExportsDir, safeName));
+ Directory.CreateDirectory(exportDir);
+
+ // Copy & rename photos — track skipped source files
+ var photoPaths = new List();
+ var sources = sourcePaths.ToList();
+ for (int i = 0; i < sources.Count; i++)
+ {
+ var src = sources[i];
+ if (!File.Exists(src)) continue; // E3: track but don't silently ignore
+
+ var ext = Path.GetExtension(src);
+ var dest = i == 0
+ ? Path.Combine(exportDir, $"{safeName}{ext}")
+ : Path.Combine(exportDir, $"{safeName}_{i + 1}{ext}");
+
+ File.Copy(src, dest, overwrite: true);
+ photoPaths.Add(dest);
+ }
+
+ // Write text file
+ var textFile = Path.Combine(exportDir, $"{safeName}.txt");
+ File.WriteAllText(textFile, BuildTextExport(title, description, price, category, conditionNotes));
+
+ var listing = new SavedListing
+ {
+ Title = title,
+ Description = description,
+ Price = price,
+ Category = category,
+ ConditionNotes = conditionNotes,
+ ExportFolder = exportDir,
+ PhotoPaths = photoPaths
+ };
+
+ _listings.Insert(0, listing); // newest first
+ Persist(); // E1: propagates on failure so caller can show error
+ return (listing, sources.Count - photoPaths.Count);
+ }
+
+ public void Delete(SavedListing listing)
+ {
+ _listings.Remove(listing);
+ Persist();
+
+ try
+ {
+ if (Directory.Exists(listing.ExportFolder))
+ Directory.Delete(listing.ExportFolder, recursive: true);
+ }
+ catch { /* ignore — user may have already deleted it */ }
+ }
+
+ // S3: use ProcessStartInfo with FileName so spaces/special chars are handled correctly
+ public void OpenExportFolder(SavedListing listing)
+ {
+ var dir = Directory.Exists(listing.ExportFolder) ? listing.ExportFolder : ExportsDir;
+ if (Directory.Exists(dir))
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = dir,
+ UseShellExecute = true
+ });
+ }
+
+ // ---- Private helpers ----
+
+ private void Load()
+ {
+ if (!File.Exists(IndexFile)) return;
+ try
+ {
+ var json = File.ReadAllText(IndexFile);
+ _listings = JsonConvert.DeserializeObject>(json) ?? new();
+ }
+ catch (Exception ex)
+ {
+ // E2: back up corrupt index rather than silently discarding all records
+ var backup = IndexFile + ".corrupt." + DateTime.Now.ToString("yyyyMMddHHmmss");
+ try { File.Move(IndexFile, backup); } catch { /* can't backup, proceed */ }
+ System.Diagnostics.Debug.WriteLine(
+ $"SavedListingsService: index corrupt, backed up to {backup}. Error: {ex.Message}");
+ _listings = new();
+ }
+ }
+
+ private void Persist()
+ {
+ // E1: let exceptions propagate so callers can surface them to the user
+ var json = JsonConvert.SerializeObject(_listings, Formatting.Indented);
+ File.WriteAllText(IndexFile, json);
+ }
+
+ private static string BuildTextExport(string title, string description,
+ decimal price, string category,
+ string conditionNotes)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"Title: {title}");
+ sb.AppendLine($"Category: {category}");
+ sb.AppendLine($"Price: £{price:F2}");
+ if (!string.IsNullOrWhiteSpace(conditionNotes))
+ sb.AppendLine($"Condition: {conditionNotes}");
+ sb.AppendLine();
+ sb.AppendLine("Description:");
+ sb.AppendLine(description);
+ return sb.ToString();
+ }
+
+ private static string MakeSafeFilename(string name)
+ {
+ // S2: replace invalid chars, then strip trailing dots/spaces Windows silently removes
+ var invalid = Path.GetInvalidFileNameChars();
+ var safe = string.Join("", name.Select(c => invalid.Contains(c) ? '_' : c)).Trim();
+ if (safe.Length > 80) safe = safe[..80];
+ safe = safe.TrimEnd('.', ' ');
+ return safe.Length > 0 ? safe : "Listing";
+ }
+
+ private static string UniqueDir(string path)
+ {
+ if (!Directory.Exists(path)) return path;
+ int i = 2;
+ while (Directory.Exists($"{path} ({i})")) i++;
+ return $"{path} ({i})";
+ }
+}
diff --git a/EbayListingTool/Views/BulkImportView.xaml b/EbayListingTool/Views/BulkImportView.xaml
new file mode 100644
index 0000000..3424ee7
--- /dev/null
+++ b/EbayListingTool/Views/BulkImportView.xaml
@@ -0,0 +1,484 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/Views/BulkImportView.xaml.cs b/EbayListingTool/Views/BulkImportView.xaml.cs
new file mode 100644
index 0000000..3d09e45
--- /dev/null
+++ b/EbayListingTool/Views/BulkImportView.xaml.cs
@@ -0,0 +1,268 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Controls;
+using EbayListingTool.Models;
+using EbayListingTool.Services;
+using Microsoft.Win32;
+
+namespace EbayListingTool.Views;
+
+public partial class BulkImportView : UserControl
+{
+ private EbayListingService? _listingService;
+ private EbayCategoryService? _categoryService;
+ private AiAssistantService? _aiService;
+ private BulkImportService? _bulkService;
+ private EbayAuthService? _auth;
+
+ private ObservableCollection _rows = new();
+
+ public BulkImportView()
+ {
+ InitializeComponent();
+ }
+
+ public void Initialise(EbayListingService listingService, EbayCategoryService categoryService,
+ AiAssistantService aiService, BulkImportService bulkService, EbayAuthService auth)
+ {
+ _listingService = listingService;
+ _categoryService = categoryService;
+ _aiService = aiService;
+ _bulkService = bulkService;
+ _auth = auth;
+ }
+
+ private void OpenFile_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new OpenFileDialog
+ {
+ Title = "Open import file",
+ Filter = "CSV/Excel files|*.csv;*.xlsx;*.xls|CSV files|*.csv|Excel files|*.xlsx;*.xls"
+ };
+ if (dlg.ShowDialog() != true) return;
+
+ try
+ {
+ var rows = _bulkService!.ImportFile(dlg.FileName);
+ _rows = new ObservableCollection(rows);
+ ItemsGrid.ItemsSource = _rows;
+
+ EmptyState.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ItemsGrid.Visibility = _rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
+
+ UpdateRowCount();
+ EnhanceAllBtn.IsEnabled = _rows.Count > 0;
+ PostAllBtn.IsEnabled = _rows.Count > 0;
+ ClearBtn.IsEnabled = _rows.Count > 0;
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(ex.Message, "Import Error", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ private void DownloadTemplate_Click(object sender, RoutedEventArgs e)
+ {
+ var templatePath = Path.Combine(AppContext.BaseDirectory, "sample-import.csv");
+ if (File.Exists(templatePath))
+ {
+ var dlg = new SaveFileDialog
+ {
+ Title = "Save CSV template",
+ FileName = "ebay-import-template.csv",
+ Filter = "CSV files|*.csv"
+ };
+ if (dlg.ShowDialog() == true)
+ {
+ File.Copy(templatePath, dlg.FileName, overwrite: true);
+ Process.Start(new ProcessStartInfo(dlg.FileName) { UseShellExecute = true });
+ }
+ }
+ else
+ {
+ MessageBox.Show("Template file not found.", "Error", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ private async void AiEnhanceAll_Click(object sender, RoutedEventArgs e)
+ {
+ if (_aiService == null || _rows.Count == 0) return;
+ EnhanceAllBtn.IsEnabled = false;
+ SetEnhanceSpinner(true);
+ ShowProgress(0);
+
+ for (int i = 0; i < _rows.Count; i++)
+ {
+ var row = _rows[i];
+ if (row.Status == BulkRowStatus.Posted) continue;
+
+ row.Status = BulkRowStatus.Enhancing;
+ row.StatusMessage = "Enhancing...";
+ try
+ {
+ await _aiService.EnhanceListingAsync(row);
+ row.Status = BulkRowStatus.Ready;
+ row.StatusMessage = "Ready to post";
+ }
+ catch (Exception ex)
+ {
+ row.Status = BulkRowStatus.Failed;
+ row.StatusMessage = $"AI error: {ex.Message}";
+ }
+
+ ShowProgress((i + 1) * 100 / _rows.Count);
+ await Task.Delay(300); // rate-limit AI calls
+ }
+
+ HideProgress();
+ SetEnhanceSpinner(false);
+ EnhanceAllBtn.IsEnabled = true;
+ UpdateSummary();
+ }
+
+ private async void PostAll_Click(object sender, RoutedEventArgs e)
+ {
+ if (_listingService == null || _rows.Count == 0) return;
+
+ var toPost = _rows.Where(r => r.Status != BulkRowStatus.Posted).ToList();
+ if (toPost.Count == 0)
+ {
+ MessageBox.Show("All items have already been posted.", "Post All",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var confirm = MessageBox.Show($"Post {toPost.Count} item(s) to eBay?", "Confirm",
+ MessageBoxButton.YesNo, MessageBoxImage.Question);
+ if (confirm != MessageBoxResult.Yes) return;
+
+ PostAllBtn.IsEnabled = false;
+ SetPostAllSpinner(true);
+ ShowProgress(0);
+
+ int posted = 0, failed = 0;
+ for (int i = 0; i < toPost.Count; i++)
+ {
+ var row = toPost[i];
+ await PostRowAsync(row);
+ if (row.Status == BulkRowStatus.Posted) posted++;
+ else failed++;
+
+ ShowProgress((i + 1) * 100 / toPost.Count);
+ }
+
+ HideProgress();
+ SetPostAllSpinner(false);
+ PostAllBtn.IsEnabled = true;
+ SummaryLabel.Text = $"Done — {_rows.Count} rows";
+ SummaryPostedPill.Text = $"{posted} posted";
+ SummaryFailedPill.Text = $"{failed} failed";
+ SummaryReadyPill.Text = $"{_rows.Count(r => r.Status == BulkRowStatus.Ready)} ready";
+ SummaryBar.Visibility = Visibility.Visible;
+ }
+
+ private async void PostRow_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as Button)?.Tag is BulkImportRow row)
+ await PostRowAsync(row);
+ }
+
+ private async Task PostRowAsync(BulkImportRow row)
+ {
+ row.Status = BulkRowStatus.Posting;
+ row.StatusMessage = "Posting...";
+ try
+ {
+ var postcode = App.Configuration["Ebay:DefaultPostcode"] ?? "";
+
+ // Resolve category
+ string? categoryId = null;
+ if (!string.IsNullOrWhiteSpace(row.CategoryKeyword))
+ categoryId = await _categoryService!.GetCategoryIdByKeywordAsync(row.CategoryKeyword);
+
+ var draft = row.ToListingDraft(postcode);
+ if (categoryId != null) draft.CategoryId = categoryId;
+
+ await _listingService!.PostListingAsync(draft);
+
+ row.Status = BulkRowStatus.Posted;
+ row.StatusMessage = $"✅ {draft.EbayItemId}";
+ }
+ catch (Exception ex)
+ {
+ row.Status = BulkRowStatus.Failed;
+ row.StatusMessage = ex.Message.Length > 50
+ ? ex.Message[..50] + "..." : ex.Message;
+ }
+ }
+
+ private async void AiEnhanceRow_Click(object sender, RoutedEventArgs e)
+ {
+ if (_aiService == null) return;
+ if ((sender as Button)?.Tag is not BulkImportRow row) return;
+
+ row.Status = BulkRowStatus.Enhancing;
+ row.StatusMessage = "Enhancing...";
+ try
+ {
+ await _aiService.EnhanceListingAsync(row);
+ row.Status = BulkRowStatus.Ready;
+ row.StatusMessage = "Ready";
+ }
+ catch (Exception ex)
+ {
+ row.Status = BulkRowStatus.Failed;
+ row.StatusMessage = ex.Message;
+ }
+ }
+
+ private void ClearAll_Click(object sender, RoutedEventArgs e)
+ {
+ _rows.Clear();
+ ItemsGrid.Visibility = Visibility.Collapsed;
+ EmptyState.Visibility = Visibility.Visible;
+ SummaryBar.Visibility = Visibility.Collapsed;
+ EnhanceAllBtn.IsEnabled = false;
+ PostAllBtn.IsEnabled = false;
+ ClearBtn.IsEnabled = false;
+ RowCountLabel.Text = "No file loaded";
+ }
+
+ private void ShowProgress(int percent)
+ {
+ ProgressBar.Value = percent;
+ ProgressBar.Visibility = Visibility.Visible;
+ }
+
+ private void HideProgress() => ProgressBar.Visibility = Visibility.Collapsed;
+
+ private void UpdateRowCount()
+ {
+ RowCountLabel.Text = $"{_rows.Count} row{(_rows.Count == 1 ? "" : "s")} loaded";
+ }
+
+ private void SetEnhanceSpinner(bool spinning)
+ {
+ EnhanceSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
+ EnhanceIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void SetPostAllSpinner(bool spinning)
+ {
+ PostAllSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
+ PostAllIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void UpdateSummary()
+ {
+ var posted = _rows.Count(r => r.Status == BulkRowStatus.Posted);
+ var failed = _rows.Count(r => r.Status == BulkRowStatus.Failed);
+ var ready = _rows.Count(r => r.Status == BulkRowStatus.Ready);
+ SummaryLabel.Text = $"{_rows.Count} row{(_rows.Count == 1 ? "" : "s")}";
+ SummaryPostedPill.Text = $"{posted} posted";
+ SummaryFailedPill.Text = $"{failed} failed";
+ SummaryReadyPill.Text = $"{ready} ready";
+ SummaryBar.Visibility = Visibility.Visible;
+ }
+}
diff --git a/EbayListingTool/Views/MainWindow.xaml b/EbayListingTool/Views/MainWindow.xaml
new file mode 100644
index 0000000..5915905
--- /dev/null
+++ b/EbayListingTool/Views/MainWindow.xaml
@@ -0,0 +1,299 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/Views/MainWindow.xaml.cs b/EbayListingTool/Views/MainWindow.xaml.cs
new file mode 100644
index 0000000..375dcf4
--- /dev/null
+++ b/EbayListingTool/Views/MainWindow.xaml.cs
@@ -0,0 +1,123 @@
+using System.Windows;
+using System.Windows.Media;
+using EbayListingTool.Models;
+using EbayListingTool.Services;
+using MahApps.Metro.Controls;
+
+namespace EbayListingTool.Views;
+
+public partial class MainWindow : MetroWindow
+{
+ private readonly EbayAuthService _auth;
+ private readonly EbayListingService _listingService;
+ private readonly EbayCategoryService _categoryService;
+ private readonly AiAssistantService _aiService;
+ private readonly BulkImportService _bulkService;
+ private readonly SavedListingsService _savedService;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ var config = App.Configuration;
+ _auth = new EbayAuthService(config);
+ _categoryService = new EbayCategoryService(_auth);
+ _listingService = new EbayListingService(_auth, _categoryService);
+ _aiService = new AiAssistantService(config);
+ _bulkService = new BulkImportService();
+ _savedService = new SavedListingsService();
+
+ // Photo Analysis tab — no eBay needed
+ PhotoView.Initialise(_aiService, _savedService);
+ PhotoView.UseDetailsRequested += OnUseDetailsRequested;
+
+ // Saved Listings tab
+ SavedView.Initialise(_savedService);
+
+ // New Listing + Bulk tabs
+ SingleView.Initialise(_listingService, _categoryService, _aiService, _auth);
+ BulkView.Initialise(_listingService, _categoryService, _aiService, _bulkService, _auth);
+
+ // Try to restore saved eBay session
+ _auth.TryLoadSavedToken();
+ UpdateConnectionState();
+ }
+
+ // ---- eBay connection ----
+
+ private async void ConnectBtn_Click(object sender, RoutedEventArgs e)
+ {
+ ConnectBtn.IsEnabled = false;
+ SetStatus("Connecting to eBay…");
+ try
+ {
+ var username = await _auth.LoginAsync();
+ SetStatus($"Connected as {username}");
+ UpdateConnectionState();
+ }
+ catch (Exception ex)
+ {
+ SetStatus("eBay connection failed.");
+ MessageBox.Show(ex.Message, "eBay Login Failed",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ finally { ConnectBtn.IsEnabled = true; }
+ }
+
+ private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
+ {
+ _auth.Disconnect();
+ UpdateConnectionState();
+ SetStatus("Disconnected from eBay.");
+ }
+
+ private void UpdateConnectionState()
+ {
+ var connected = _auth.IsConnected;
+
+ // Per-tab overlays (Photo Analysis tab has no overlay)
+ NewListingOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
+ BulkOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
+
+ ConnectBtn.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
+ DisconnectBtn.Visibility = connected ? Visibility.Visible : Visibility.Collapsed;
+
+ if (connected)
+ {
+ StatusDot.Fill = new SolidColorBrush(Colors.LimeGreen);
+ StatusLabel.Text = $"eBay: {_auth.ConnectedUsername}";
+ StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen);
+ StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}";
+ StatusBarEbay.Foreground = new SolidColorBrush(Colors.LimeGreen);
+ }
+ else
+ {
+ StatusDot.Fill = new SolidColorBrush(Colors.Gray);
+ StatusLabel.Text = "eBay: not connected";
+ StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
+ StatusBarEbay.Text = "eBay: disconnected";
+ StatusBarEbay.Foreground = (Brush)FindResource("MahApps.Brushes.Gray5");
+ }
+ }
+
+ // ---- Photo Analysis → New Listing handoff ----
+
+ private void OnUseDetailsRequested(PhotoAnalysisResult result, IReadOnlyList photoPaths, decimal price)
+ {
+ SingleView.PopulateFromAnalysis(result, photoPaths, price); // Q1: forward all photos
+ }
+
+ public void SwitchToNewListingTab()
+ {
+ MainTabs.SelectedItem = NewListingTab;
+ }
+
+ public void RefreshSavedListings()
+ {
+ SavedView.RefreshList();
+ }
+
+ // ---- Helpers ----
+
+ public void SetStatus(string message) => StatusBar.Text = message;
+}
diff --git a/EbayListingTool/Views/PhotoAnalysisView.xaml b/EbayListingTool/Views/PhotoAnalysisView.xaml
new file mode 100644
index 0000000..6fb16d2
--- /dev/null
+++ b/EbayListingTool/Views/PhotoAnalysisView.xaml
@@ -0,0 +1,629 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/Views/PhotoAnalysisView.xaml.cs b/EbayListingTool/Views/PhotoAnalysisView.xaml.cs
new file mode 100644
index 0000000..f55a34b
--- /dev/null
+++ b/EbayListingTool/Views/PhotoAnalysisView.xaml.cs
@@ -0,0 +1,589 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using EbayListingTool.Models;
+using EbayListingTool.Services;
+using Microsoft.Win32;
+
+namespace EbayListingTool.Views;
+
+public partial class PhotoAnalysisView : UserControl
+{
+ private AiAssistantService? _aiService;
+ private SavedListingsService? _savedService;
+ private List _currentImagePaths = new();
+ private PhotoAnalysisResult? _lastResult;
+ private int _activePhotoIndex = 0;
+ private DispatcherTimer? _saveBtnTimer; // M1: field so we can stop it on Unloaded
+ private DispatcherTimer? _holdTimer; // Q2: field so we can stop it on Unloaded
+
+ private const int MaxPhotos = 4;
+
+ // Loading step cycling
+ private readonly DispatcherTimer _loadingTimer;
+ private int _loadingStep;
+ private static readonly string[] LoadingSteps =
+ [
+ "Examining the photo\u2026",
+ "Identifying the item\u2026",
+ "Researching eBay prices\u2026",
+ "Writing description\u2026"
+ ];
+
+ // Event raised when user clicks "Use for New Listing" — Q1: passes all loaded photos
+ public event Action, decimal>? UseDetailsRequested;
+
+ public PhotoAnalysisView()
+ {
+ InitializeComponent();
+
+ _loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
+ _loadingTimer.Tick += LoadingTimer_Tick;
+
+ // M1 / Q2: stop timers when control is unloaded to avoid memory leaks
+ Unloaded += (_, _) => { _saveBtnTimer?.Stop(); _holdTimer?.Stop(); };
+
+ // Keep photo clip geometry in sync with container size
+ PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
+ }
+
+ public void Initialise(AiAssistantService aiService, SavedListingsService savedService)
+ {
+ _aiService = aiService;
+ _savedService = savedService;
+ }
+
+ // ---- Photo clip geometry sync ----
+
+ private void PhotoPreviewContainer_SizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ PhotoClip.Rect = new System.Windows.Rect(0, 0, e.NewSize.Width, e.NewSize.Height);
+ }
+
+ // ---- Drop zone ----
+
+ private void DropZone_DragOver(object sender, DragEventArgs e)
+ {
+ e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
+ ? DragDropEffects.Copy : DragDropEffects.None;
+ e.Handled = true;
+ }
+
+ private void DropZone_DragEnter(object sender, DragEventArgs e)
+ {
+ // Solid accent border on drag-enter
+ DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Accent");
+ DropBorderRect.StrokeDashArray = null;
+ DropBorderRect.StrokeThickness = 2.5;
+ }
+
+ private void DropZone_DragLeave(object sender, DragEventArgs e)
+ {
+ DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Gray6");
+ DropBorderRect.StrokeDashArray = new DoubleCollection([6, 4]);
+ DropBorderRect.StrokeThickness = 2;
+ }
+
+ private void DropZone_Drop(object sender, DragEventArgs e)
+ {
+ DropZone_DragLeave(sender, e);
+ if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
+ var files = (string[])e.Data.GetData(DataFormats.FileDrop);
+ foreach (var file in files.Where(IsImageFile))
+ {
+ LoadPhoto(file);
+ if (_currentImagePaths.Count >= MaxPhotos) break;
+ }
+ }
+
+ private void DropZone_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ var dlg = new OpenFileDialog
+ {
+ Title = "Select photo(s) of the item",
+ Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp|All files|*.*",
+ Multiselect = true
+ };
+ if (dlg.ShowDialog() != true) return;
+ foreach (var file in dlg.FileNames)
+ {
+ LoadPhoto(file);
+ if (_currentImagePaths.Count >= MaxPhotos) break;
+ }
+ }
+
+ private void ClearPhoto_Click(object sender, RoutedEventArgs e)
+ {
+ _currentImagePaths.Clear();
+ _activePhotoIndex = 0;
+ PhotoPreview.Source = null;
+ PhotoPreviewContainer.Visibility = Visibility.Collapsed;
+ ClearPhotoBtn.Visibility = Visibility.Collapsed;
+ DropHint.Visibility = Visibility.Visible;
+ PhotoFilename.Text = "";
+ AnalyseBtn.IsEnabled = false;
+
+ UpdateThumbStrip();
+ ResultsPanel.Visibility = Visibility.Collapsed;
+ ResultsPanel.Opacity = 0;
+ IdlePanel.Visibility = Visibility.Visible;
+ }
+
+ ///
+ /// Adds to the photo list (up to MaxPhotos).
+ /// The preview always shows the most recently added image.
+ ///
+ private void LoadPhoto(string path)
+ {
+ if (_currentImagePaths.Contains(path)) return;
+ if (_currentImagePaths.Count >= MaxPhotos)
+ {
+ MessageBox.Show($"You can add up to {MaxPhotos} photos. Remove one before adding more.",
+ "Photo limit reached", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ try
+ {
+ _currentImagePaths.Add(path);
+
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.DecodePixelWidth = 600;
+ bmp.EndInit();
+ bmp.Freeze(); // M2: cross-thread safe, reduces GC pressure
+
+ PhotoPreview.Source = bmp;
+ PhotoPreviewContainer.Visibility = Visibility.Visible;
+ ClearPhotoBtn.Visibility = Visibility.Visible;
+ DropHint.Visibility = Visibility.Collapsed;
+
+ _activePhotoIndex = _currentImagePaths.Count - 1;
+
+ UpdatePhotoFilenameLabel();
+ UpdateThumbStrip();
+ AnalyseBtn.IsEnabled = true;
+
+ // Collapse results so user re-analyses after adding photos
+ ResultsPanel.Visibility = Visibility.Collapsed;
+ ResultsPanel.Opacity = 0;
+ IdlePanel.Visibility = Visibility.Visible;
+ }
+ catch (Exception ex)
+ {
+ _currentImagePaths.Remove(path);
+ MessageBox.Show($"Could not load image: {ex.Message}", "Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ private void UpdatePhotoFilenameLabel()
+ {
+ PhotoFilename.Text = _currentImagePaths.Count switch
+ {
+ 0 => "",
+ 1 => Path.GetFileName(_currentImagePaths[0]),
+ _ => $"{_currentImagePaths.Count} photos loaded"
+ };
+ }
+
+ // ---- Analyse ----
+
+ private async void Analyse_Click(object sender, RoutedEventArgs e)
+ {
+ if (_aiService == null || _currentImagePaths.Count == 0) return;
+
+ SetAnalysing(true);
+ try
+ {
+ var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
+ _lastResult = result;
+ ShowResults(result);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ finally
+ {
+ SetAnalysing(false);
+ }
+ }
+
+ // Re-analyse simply repeats the same call — idempotent by design
+ private void ReAnalyse_Click(object sender, RoutedEventArgs e)
+ => Analyse_Click(sender, e);
+
+ private void ShowResults(PhotoAnalysisResult r)
+ {
+ IdlePanel.Visibility = Visibility.Collapsed;
+ LoadingPanel.Visibility = Visibility.Collapsed;
+ ResultsPanel.Visibility = Visibility.Visible;
+
+ // Item identification
+ ItemNameText.Text = r.ItemName;
+
+ var brandModel = string.Join(" \u00b7 ",
+ new[] { r.Brand, r.Model }.Where(s => !string.IsNullOrWhiteSpace(s)));
+ if (!string.IsNullOrWhiteSpace(brandModel))
+ {
+ BrandModelText.Text = brandModel;
+ BrandPill.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ BrandPill.Visibility = Visibility.Collapsed;
+ }
+
+ ConditionText.Text = r.ConditionNotes;
+
+ // Confidence badge
+ ConfidenceBadge.Visibility = Visibility.Collapsed;
+ if (!string.IsNullOrWhiteSpace(r.IdentificationConfidence))
+ {
+ ConfidenceText.Text = r.IdentificationConfidence;
+ ConfidenceBadge.Background = r.IdentificationConfidence.ToLower() switch
+ {
+ "high" => new SolidColorBrush(Color.FromRgb(34, 139, 34)),
+ "medium" => new SolidColorBrush(Color.FromRgb(210, 140, 0)),
+ _ => new SolidColorBrush(Color.FromRgb(192, 0, 0))
+ };
+ ConfidenceBadge.Visibility = Visibility.Visible;
+ }
+ ConfidenceNotesText.Text = r.ConfidenceNotes;
+ ConfidenceNotesText.Visibility = string.IsNullOrWhiteSpace(r.ConfidenceNotes)
+ ? Visibility.Collapsed : Visibility.Visible;
+
+ // Price badge
+ PriceSuggestedText.Text = r.PriceSuggested > 0 ? $"\u00a3{r.PriceSuggested:F2}" : "\u2014";
+
+ // Price range bar
+ if (r.PriceMin > 0 && r.PriceMax > 0)
+ {
+ PriceMinText.Text = $"\u00a3{r.PriceMin:F2}";
+ PriceMaxText.Text = $"\u00a3{r.PriceMax:F2}";
+ PriceRangeBar.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ PriceRangeBar.Visibility = Visibility.Collapsed;
+ }
+
+ PriceOverride.Value = (double)r.PriceSuggested;
+
+ // Price reasoning
+ PriceReasoningText.Text = r.PriceReasoning;
+ PriceReasoningText.Visibility = string.IsNullOrWhiteSpace(r.PriceReasoning)
+ ? Visibility.Collapsed : Visibility.Visible;
+
+ // Editable fields
+ TitleBox.Text = r.Title;
+ DescriptionBox.Text = r.Description;
+
+ // Animate results in
+ var sb = (Storyboard)FindResource("ResultsReveal");
+ sb.Begin(this);
+ }
+
+ private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ var len = TitleBox.Text.Length;
+ TitleCount.Text = $"{len} / 80";
+ TitleCount.Foreground = len > 75
+ ? Brushes.OrangeRed
+ : (Brush)FindResource("MahApps.Brushes.Gray5");
+ }
+
+ private void UseDetails_Click(object sender, RoutedEventArgs e)
+ {
+ if (_lastResult == null) return;
+
+ // Copy any edits back into result before passing on
+ _lastResult.Title = TitleBox.Text;
+ _lastResult.Description = DescriptionBox.Text;
+
+ var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
+ UseDetailsRequested?.Invoke(_lastResult, _currentImagePaths, price); // Q1: pass all photos
+
+ // Switch to New Listing tab
+ if (Window.GetWindow(this) is MainWindow mw)
+ mw.SwitchToNewListingTab();
+
+ GetWindow()?.SetStatus($"Details loaded for: {_lastResult.Title}");
+ }
+
+ private void SaveListing_Click(object sender, RoutedEventArgs e)
+ {
+ if (_lastResult == null || _savedService == null) return;
+
+ // Use edited title/description if the user changed them
+ var title = TitleBox.Text.Trim();
+ var description = DescriptionBox.Text.Trim();
+ var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
+
+ _savedService.Save(title, description, price,
+ _lastResult.CategoryKeyword, _lastResult.ConditionNotes,
+ _currentImagePaths);
+
+ // Brief visual confirmation on the button — M1: use field timer, stop previous if re-saved quickly
+ _saveBtnTimer?.Stop();
+ SaveIcon.Visibility = Visibility.Collapsed;
+ SavedIcon.Visibility = Visibility.Visible;
+ SaveBtnText.Text = "Saved!";
+
+ _saveBtnTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
+ _saveBtnTimer.Tick += (s, _) =>
+ {
+ _saveBtnTimer?.Stop();
+ SaveIcon.Visibility = Visibility.Visible;
+ SavedIcon.Visibility = Visibility.Collapsed;
+ SaveBtnText.Text = "Save Listing";
+ };
+ _saveBtnTimer.Start();
+
+ ShowSaveToast();
+
+ // Notify main window to refresh the gallery
+ (Window.GetWindow(this) as MainWindow)?.RefreshSavedListings();
+ GetWindow()?.SetStatus($"Saved: {title}");
+ }
+
+ private void AnalyseAnother_Click(object sender, RoutedEventArgs e)
+ {
+ _currentImagePaths.Clear();
+ _lastResult = null;
+ _activePhotoIndex = 0;
+ PhotoPreview.Source = null;
+ PhotoPreviewContainer.Visibility = Visibility.Collapsed;
+ ClearPhotoBtn.Visibility = Visibility.Collapsed;
+ DropHint.Visibility = Visibility.Visible;
+ PhotoFilename.Text = "";
+ AnalyseBtn.IsEnabled = false;
+ UpdateThumbStrip();
+ ResultsPanel.Visibility = Visibility.Collapsed;
+ ResultsPanel.Opacity = 0;
+ IdlePanel.Visibility = Visibility.Visible;
+ }
+
+ // ---- Thumb strip ----
+
+ ///
+ /// Rebuilds the thumbnail strip from .
+ /// Shows/hides the strip based on whether 2+ photos are loaded.
+ ///
+ private void UpdateThumbStrip()
+ {
+ PhotoThumbStrip.Children.Clear();
+
+ if (_currentImagePaths.Count < 2)
+ {
+ ThumbStripScroller.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ ThumbStripScroller.Visibility = Visibility.Visible;
+
+ var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
+ var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
+
+ for (int i = 0; i < _currentImagePaths.Count; i++)
+ {
+ var index = i; // capture for closure
+ var path = _currentImagePaths[i];
+
+ BitmapImage? thumb = null;
+ try
+ {
+ thumb = new BitmapImage();
+ thumb.BeginInit();
+ thumb.UriSource = new Uri(path, UriKind.Absolute); // W1
+ thumb.CacheOption = BitmapCacheOption.OnLoad;
+ thumb.DecodePixelWidth = 80;
+ thumb.EndInit();
+ thumb.Freeze(); // M2
+ }
+ catch
+ {
+ // Skip thumbnails that fail to load
+ continue;
+ }
+
+ bool isActive = (index == _activePhotoIndex);
+
+ var img = new Image
+ {
+ Source = thumb,
+ Width = 60,
+ Height = 60,
+ Stretch = Stretch.UniformToFill
+ };
+ RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.HighQuality);
+ // Clip image to rounded rect
+ img.Clip = new System.Windows.Media.RectangleGeometry(
+ new System.Windows.Rect(0, 0, 60, 60), 4, 4);
+
+ var border = new Border
+ {
+ Width = 64,
+ Height = 64,
+ Margin = new Thickness(3),
+ CornerRadius = new CornerRadius(5),
+ BorderThickness = new Thickness(isActive ? 2.5 : 1.5),
+ BorderBrush = isActive ? accentBrush : neutralBrush,
+ Background = System.Windows.Media.Brushes.Transparent,
+ Cursor = System.Windows.Input.Cursors.Hand,
+ Child = img
+ };
+
+ border.MouseLeftButtonUp += (_, _) =>
+ {
+ _activePhotoIndex = index;
+
+ // Load that photo into main preview
+ try
+ {
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.UriSource = new Uri(_currentImagePaths[index], UriKind.Absolute); // W1
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.DecodePixelWidth = 600;
+ bmp.EndInit();
+ bmp.Freeze(); // M2
+ PhotoPreview.Source = bmp;
+ }
+ catch { /* silently ignore */ }
+
+ // Q3: full rebuild avoids index-desync when thumbnails skipped on load error
+ UpdateThumbStrip();
+ };
+
+ PhotoThumbStrip.Children.Add(border);
+ }
+ }
+
+ ///
+ /// Updates only the border highlights on the existing thumb strip children
+ /// after the active index changes, avoiding a full thumbnail reload.
+ ///
+ private void UpdateThumbStripHighlight()
+ {
+ var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
+ var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
+
+ int childIndex = 0;
+ for (int i = 0; i < _currentImagePaths.Count; i++)
+ {
+ if (childIndex >= PhotoThumbStrip.Children.Count) break;
+ if (PhotoThumbStrip.Children[childIndex] is Border b)
+ {
+ bool isActive = (i == _activePhotoIndex);
+ b.BorderBrush = isActive ? accentBrush : neutralBrush;
+ b.BorderThickness = new Thickness(isActive ? 2.5 : 1.5);
+ }
+ childIndex++;
+ }
+ }
+
+ // ---- Save toast ----
+
+ private bool _toastAnimating = false;
+
+ private void ShowSaveToast()
+ {
+ if (_toastAnimating) return;
+ _toastAnimating = true;
+
+ SaveToast.Visibility = Visibility.Visible;
+
+ // Slide in: Y from +40 to 0
+ var slideIn = new DoubleAnimation(40, 0, new Duration(TimeSpan.FromMilliseconds(220)))
+ {
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
+ };
+
+ // After 2.5 s total: slide out Y from 0 to +40, then hide
+ slideIn.Completed += (_, _) =>
+ {
+ _holdTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(2500 - 220) }; // Q2: field
+ _holdTimer.Tick += (s2, _) =>
+ {
+ _holdTimer.Stop();
+ var slideOut = new DoubleAnimation(0, 40, new Duration(TimeSpan.FromMilliseconds(180)))
+ {
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
+ };
+ slideOut.Completed += (_, _) =>
+ {
+ SaveToast.Visibility = Visibility.Collapsed;
+ _toastAnimating = false;
+ };
+ ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut);
+ };
+ _holdTimer.Start();
+ };
+
+ ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideIn);
+ }
+
+ // ---- Copy buttons ----
+
+ private void CopyTitle_Click(object sender, RoutedEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(TitleBox.Text))
+ Clipboard.SetText(TitleBox.Text);
+ }
+
+ private void CopyDescription_Click(object sender, RoutedEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(DescriptionBox.Text))
+ Clipboard.SetText(DescriptionBox.Text);
+ }
+
+ // ---- Loading step cycling ----
+
+ private void LoadingTimer_Tick(object? sender, EventArgs e)
+ {
+ _loadingStep = (_loadingStep + 1) % LoadingSteps.Length;
+ LoadingStepText.Text = LoadingSteps[_loadingStep];
+ }
+
+ // ---- Helpers ----
+
+ private void SetAnalysing(bool busy)
+ {
+ AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
+ AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
+ AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI";
+ AnalyseBtn.IsEnabled = !busy;
+ IsEnabled = !busy;
+
+ if (busy)
+ {
+ IdlePanel.Visibility = Visibility.Collapsed;
+ ResultsPanel.Visibility = Visibility.Collapsed;
+ LoadingPanel.Visibility = Visibility.Visible;
+ _loadingStep = 0;
+ LoadingStepText.Text = LoadingSteps[0];
+ _loadingTimer.Start();
+ }
+ else
+ {
+ _loadingTimer.Stop();
+ LoadingPanel.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private static bool IsImageFile(string path)
+ {
+ var ext = Path.GetExtension(path).ToLower();
+ return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
+ }
+
+ private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
+}
diff --git a/EbayListingTool/Views/SavedListingsView.xaml b/EbayListingTool/Views/SavedListingsView.xaml
new file mode 100644
index 0000000..a751a15
--- /dev/null
+++ b/EbayListingTool/Views/SavedListingsView.xaml
@@ -0,0 +1,316 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/Views/SavedListingsView.xaml.cs b/EbayListingTool/Views/SavedListingsView.xaml.cs
new file mode 100644
index 0000000..338e04d
--- /dev/null
+++ b/EbayListingTool/Views/SavedListingsView.xaml.cs
@@ -0,0 +1,372 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using System.Windows.Media.Imaging;
+using EbayListingTool.Models;
+using EbayListingTool.Services;
+
+namespace EbayListingTool.Views;
+
+public partial class SavedListingsView : UserControl
+{
+ private SavedListingsService? _service;
+ private SavedListing? _selected;
+
+ // Normal card background — resolved once after load so we can restore it on mouse-leave
+ private Brush? _cardNormalBg;
+ private Brush? _cardHoverBg;
+
+ public SavedListingsView()
+ {
+ InitializeComponent();
+ Loaded += (_, _) =>
+ {
+ _cardNormalBg = (Brush)FindResource("MahApps.Brushes.Gray10");
+ _cardHoverBg = (Brush)FindResource("MahApps.Brushes.Gray9");
+ };
+ }
+
+ public void Initialise(SavedListingsService service)
+ {
+ _service = service;
+ RefreshList();
+ }
+
+ // ---- Public refresh (called after a new save) ----
+
+ public void RefreshList()
+ {
+ if (_service == null) return;
+
+ var listings = _service.Listings;
+ ListingCountText.Text = listings.Count == 1 ? "1 saved listing" : $"{listings.Count} saved listings";
+
+ ApplyFilter(SearchBox.Text, listings);
+
+ // Re-select if we had one selected
+ if (_selected != null)
+ {
+ var stillExists = listings.FirstOrDefault(l => l.Id == _selected.Id);
+ if (stillExists != null) ShowDetail(stillExists, animate: false);
+ else ClearDetail();
+ }
+ }
+
+ // ---- Search filter ----
+
+ private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (_service == null) return;
+ ApplyFilter(SearchBox.Text, _service.Listings);
+ }
+
+ private void ApplyFilter(string query, IReadOnlyList listings)
+ {
+ CardPanel.Children.Clear();
+
+ var filtered = string.IsNullOrWhiteSpace(query)
+ ? listings
+ : listings.Where(l => l.Title.Contains(query, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ // Empty states
+ if (listings.Count == 0)
+ {
+ EmptyCardState.Visibility = Visibility.Visible;
+ EmptyFilterState.Visibility = Visibility.Collapsed;
+ }
+ else if (filtered.Count == 0)
+ {
+ EmptyCardState.Visibility = Visibility.Collapsed;
+ EmptyFilterState.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ EmptyCardState.Visibility = Visibility.Collapsed;
+ EmptyFilterState.Visibility = Visibility.Collapsed;
+ }
+
+ foreach (var listing in filtered)
+ CardPanel.Children.Add(BuildCard(listing));
+ }
+
+ // ---- Card builder ----
+
+ private Border BuildCard(SavedListing listing)
+ {
+ var isSelected = _selected?.Id == listing.Id;
+
+ var card = new Border
+ {
+ Style = (Style)FindResource(isSelected ? "SelectedCardBorder" : "CardBorder")
+ };
+
+ var grid = new Grid { Margin = new Thickness(10, 10, 10, 10) };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(64) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ // Thumbnail container — relative panel so we can overlay the badge
+ var thumbContainer = new Grid { Width = 60, Height = 60, Margin = new Thickness(0, 0, 10, 0) };
+
+ var thumb = new Border
+ {
+ Width = 60, Height = 60,
+ CornerRadius = new CornerRadius(5),
+ Background = (Brush)FindResource("MahApps.Brushes.Gray8"),
+ ClipToBounds = true
+ };
+
+ if (!string.IsNullOrEmpty(listing.FirstPhotoPath) && File.Exists(listing.FirstPhotoPath))
+ {
+ try
+ {
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.UriSource = new Uri(listing.FirstPhotoPath, UriKind.Absolute); // W1
+ bmp.DecodePixelWidth = 120;
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.EndInit();
+ bmp.Freeze(); // M2
+
+ thumb.Child = new Image
+ {
+ Source = bmp,
+ Stretch = Stretch.UniformToFill
+ };
+ }
+ catch { AddPhotoIcon(thumb); }
+ }
+ else
+ {
+ AddPhotoIcon(thumb);
+ }
+
+ thumbContainer.Children.Add(thumb);
+
+ // Photo count badge — shown only when there are 2+ photos
+ if (listing.PhotoPaths.Count >= 2)
+ {
+ var badge = new Border
+ {
+ CornerRadius = new CornerRadius(3),
+ Background = new SolidColorBrush(Color.FromArgb(200, 0, 0, 0)),
+ Padding = new Thickness(4, 1, 4, 1),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(0, 0, 2, 2)
+ };
+ badge.Child = new TextBlock
+ {
+ Text = $"{listing.PhotoPaths.Count} photos",
+ FontSize = 9,
+ Foreground = Brushes.White
+ };
+ thumbContainer.Children.Add(badge);
+ }
+
+ Grid.SetColumn(thumbContainer, 0);
+ grid.Children.Add(thumbContainer);
+
+ // Text block
+ var textStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
+ Grid.SetColumn(textStack, 1);
+
+ textStack.Children.Add(new TextBlock
+ {
+ Text = listing.Title,
+ FontSize = 12,
+ FontWeight = FontWeights.SemiBold,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ Foreground = (Brush)FindResource("MahApps.Brushes.Gray1"),
+ Margin = new Thickness(0, 0, 0, 3)
+ });
+
+ var priceRow = new StackPanel { Orientation = Orientation.Horizontal };
+ priceRow.Children.Add(new TextBlock
+ {
+ Text = listing.PriceDisplay,
+ FontSize = 13,
+ FontWeight = FontWeights.Bold,
+ Foreground = (Brush)FindResource("MahApps.Brushes.Accent"),
+ Margin = new Thickness(0, 0, 8, 0)
+ });
+ textStack.Children.Add(priceRow);
+
+ textStack.Children.Add(new TextBlock
+ {
+ Text = listing.SavedAtDisplay,
+ FontSize = 10,
+ Foreground = (Brush)FindResource("MahApps.Brushes.Gray5"),
+ Margin = new Thickness(0, 3, 0, 0)
+ });
+
+ grid.Children.Add(textStack);
+ card.Child = grid;
+
+ // Hover effect — only for non-selected cards
+ card.MouseEnter += (s, e) =>
+ {
+ if (_selected?.Id != listing.Id && _cardHoverBg != null)
+ card.Background = _cardHoverBg;
+ };
+ card.MouseLeave += (s, e) =>
+ {
+ if (_selected?.Id != listing.Id && _cardNormalBg != null)
+ card.Background = _cardNormalBg;
+ };
+
+ card.MouseLeftButtonUp += (s, e) =>
+ {
+ _selected = listing;
+ ShowDetail(listing, animate: true);
+ // Rebuild cards to update selection styling without re-filtering
+ if (_service != null)
+ ApplyFilter(SearchBox.Text, _service.Listings);
+ };
+
+ return card;
+ }
+
+ private static void AddPhotoIcon(Border thumb)
+ {
+ // placeholder icon when no photo
+ thumb.Child = new MahApps.Metro.IconPacks.PackIconMaterial
+ {
+ Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ImageOutline,
+ Width = 28, Height = 28,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = SystemColors.GrayTextBrush
+ };
+ }
+
+ // ---- Detail panel ----
+
+ private void ShowDetail(SavedListing listing, bool animate = true)
+ {
+ _selected = listing;
+
+ EmptyDetail.Visibility = Visibility.Collapsed;
+ DetailPanel.Visibility = Visibility.Visible;
+
+ DetailTitle.Text = listing.Title;
+ DetailPrice.Text = listing.PriceDisplay;
+ DetailCategory.Text = listing.Category;
+ DetailDate.Text = listing.SavedAtDisplay;
+ DetailDescription.Text = listing.Description;
+
+ if (!string.IsNullOrWhiteSpace(listing.ConditionNotes))
+ {
+ DetailCondition.Text = listing.ConditionNotes;
+ DetailConditionRow.Visibility = Visibility.Visible;
+ }
+ else
+ {
+ DetailConditionRow.Visibility = Visibility.Collapsed;
+ }
+
+ // Photos strip
+ DetailPhotosPanel.Children.Clear();
+ foreach (var path in listing.PhotoPaths)
+ {
+ if (!File.Exists(path)) continue;
+ try
+ {
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
+ bmp.DecodePixelWidth = 200;
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.EndInit();
+ bmp.Freeze(); // M2
+
+ var img = new Image
+ {
+ Source = bmp,
+ Width = 120, Height = 120,
+ Stretch = Stretch.UniformToFill,
+ Margin = new Thickness(0, 0, 8, 0),
+ Cursor = Cursors.Hand,
+ ToolTip = Path.GetFileName(path)
+ };
+ img.Clip = new RectangleGeometry(new Rect(0, 0, 120, 120), 5, 5);
+ img.MouseLeftButtonUp += (s, e) => OpenImage(path);
+ DetailPhotosPanel.Children.Add(img);
+ }
+ catch { /* skip broken images */ }
+ }
+
+ // Fade-in animation
+ if (animate)
+ {
+ DetailPanel.Opacity = 0;
+ var fadeIn = new DoubleAnimation(0, 1, new Duration(TimeSpan.FromMilliseconds(200)))
+ {
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
+ };
+ DetailPanel.BeginAnimation(OpacityProperty, fadeIn);
+ }
+ else
+ {
+ DetailPanel.Opacity = 1;
+ }
+ }
+
+ private void ClearDetail()
+ {
+ _selected = null;
+ EmptyDetail.Visibility = Visibility.Visible;
+ DetailPanel.Visibility = Visibility.Collapsed;
+ DetailPanel.Opacity = 0;
+ }
+
+ private static void OpenImage(string path)
+ {
+ try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true }); }
+ catch { }
+ }
+
+ // ---- Button handlers ----
+
+ private void OpenExportsDir_Click(object sender, RoutedEventArgs e)
+ {
+ var exportsDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "EbayListingTool", "Exports");
+ if (!Directory.Exists(exportsDir)) Directory.CreateDirectory(exportsDir);
+ System.Diagnostics.Process.Start("explorer.exe", exportsDir);
+ }
+
+ private void OpenFolderDetail_Click(object sender, RoutedEventArgs e)
+ {
+ if (_selected != null) _service?.OpenExportFolder(_selected);
+ }
+
+ private void CopyTitle_Click(object sender, RoutedEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(_selected?.Title))
+ Clipboard.SetText(_selected.Title);
+ }
+
+ private void CopyDescription_Click(object sender, RoutedEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(_selected?.Description))
+ Clipboard.SetText(_selected.Description);
+ }
+
+ private void DeleteListing_Click(object sender, RoutedEventArgs e)
+ {
+ if (_selected == null || _service == null) return;
+
+ var result = MessageBox.Show(
+ $"Delete \"{_selected.Title}\"?\n\nThis will also remove the export folder from disk.",
+ "Delete Listing", MessageBoxButton.YesNo, MessageBoxImage.Question);
+
+ if (result != MessageBoxResult.Yes) return;
+
+ _service.Delete(_selected);
+ ClearDetail();
+ RefreshList();
+ }
+}
diff --git a/EbayListingTool/Views/SingleItemView.xaml b/EbayListingTool/Views/SingleItemView.xaml
new file mode 100644
index 0000000..aeddc5e
--- /dev/null
+++ b/EbayListingTool/Views/SingleItemView.xaml
@@ -0,0 +1,566 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/Views/SingleItemView.xaml.cs b/EbayListingTool/Views/SingleItemView.xaml.cs
new file mode 100644
index 0000000..cd9ca89
--- /dev/null
+++ b/EbayListingTool/Views/SingleItemView.xaml.cs
@@ -0,0 +1,567 @@
+using System.Diagnostics;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media.Imaging;
+using EbayListingTool.Models;
+using EbayListingTool.Services;
+using Microsoft.Win32;
+
+namespace EbayListingTool.Views;
+
+public partial class SingleItemView : UserControl
+{
+ private EbayListingService? _listingService;
+ private EbayCategoryService? _categoryService;
+ private AiAssistantService? _aiService;
+ private EbayAuthService? _auth;
+
+ private ListingDraft _draft = new();
+ private System.Threading.CancellationTokenSource? _categoryCts;
+ private string _suggestedPriceValue = "";
+
+ public SingleItemView()
+ {
+ InitializeComponent();
+ PostcodeBox.TextChanged += (s, e) => _draft.Postcode = PostcodeBox.Text;
+ }
+
+ private void UserControl_Loaded(object sender, RoutedEventArgs e)
+ {
+ // Re-run the count bar calculations now that the layout has rendered
+ // and the track Border has a non-zero ActualWidth.
+ TitleBox_TextChanged(this, null!);
+ DescriptionBox_TextChanged(this, null!);
+ }
+
+ public void Initialise(EbayListingService listingService, EbayCategoryService categoryService,
+ AiAssistantService aiService, EbayAuthService auth)
+ {
+ _listingService = listingService;
+ _categoryService = categoryService;
+ _aiService = aiService;
+ _auth = auth;
+
+ PostcodeBox.Text = App.Configuration["Ebay:DefaultPostcode"] ?? "";
+ }
+
+ /// Pre-fills the form from a Photo Analysis result.
+ public 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.
+ _draft = new ListingDraft { Postcode = PostcodeBox.Text };
+ TitleBox.Text = "";
+ DescriptionBox.Text = "";
+ CategoryBox.Text = "";
+ CategoryIdLabel.Text = "(no category)";
+ PriceBox.Value = 0;
+ QuantityBox.Value = 1;
+ ConditionBox.SelectedIndex = 3; // Used
+ FormatBox.SelectedIndex = 0;
+ PhotosPanel.Children.Clear();
+ UpdatePhotoPanel();
+ SuccessPanel.Visibility = Visibility.Collapsed;
+ PriceSuggestionPanel.Visibility = Visibility.Collapsed;
+
+ TitleBox.Text = result.Title;
+ DescriptionBox.Text = result.Description;
+ PriceBox.Value = (double)price;
+ CategoryBox.Text = result.CategoryKeyword;
+
+ _draft.CategoryName = result.CategoryKeyword;
+
+ // Q1: load all photos from analysis
+ var validPaths = imagePaths.Where(p => !string.IsNullOrEmpty(p) && File.Exists(p)).ToArray();
+ if (validPaths.Length > 0)
+ AddPhotos(validPaths);
+ }
+
+ // ---- Title ----
+
+ private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ _draft.Title = TitleBox.Text;
+ var len = TitleBox.Text.Length;
+ TitleCount.Text = $"{len} / 80";
+
+ var overLimit = len > 75;
+ TitleCount.Foreground = overLimit
+ ? System.Windows.Media.Brushes.OrangeRed
+ : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
+
+ // Update the progress bar fill width proportionally
+ var trackBorder = TitleCountBar.Parent as Border;
+ double trackWidth = trackBorder?.ActualWidth ?? 0;
+ if (trackWidth > 0)
+ TitleCountBar.Width = trackWidth * (len / 80.0);
+
+ TitleCountBar.Background = overLimit
+ ? System.Windows.Media.Brushes.OrangeRed
+ : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
+ }
+
+ private async void AiTitle_Click(object sender, RoutedEventArgs e)
+ {
+ if (_aiService == null) return;
+ var condition = GetSelectedCondition().ToString();
+ var current = TitleBox.Text;
+
+ SetTitleSpinner(true);
+ SetBusy(true, "Generating title...");
+ try
+ {
+ var title = await _aiService.GenerateTitleAsync(current, condition);
+ TitleBox.Text = title.Trim().TrimEnd('.').Trim('"');
+ }
+ catch (Exception ex)
+ {
+ ShowError("AI Title", ex.Message);
+ }
+ finally { SetBusy(false); SetTitleSpinner(false); }
+ }
+
+ // ---- Category ----
+
+ private async void CategoryBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ _categoryCts?.Cancel();
+ _categoryCts?.Dispose();
+ _categoryCts = new System.Threading.CancellationTokenSource();
+ var cts = _categoryCts;
+
+ if (CategoryBox.Text.Length < 3)
+ {
+ CategorySuggestionsList.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ try
+ {
+ await Task.Delay(400, cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ if (cts.IsCancellationRequested) return;
+
+ try
+ {
+ var suggestions = await _categoryService!.GetCategorySuggestionsAsync(CategoryBox.Text);
+ if (cts.IsCancellationRequested) return;
+
+ Dispatcher.Invoke(() =>
+ {
+ CategorySuggestionsList.ItemsSource = suggestions;
+ CategorySuggestionsList.Visibility = suggestions.Count > 0
+ ? Visibility.Visible : Visibility.Collapsed;
+ });
+ }
+ catch (OperationCanceledException) { /* superseded by newer keystroke */ }
+ catch { /* ignore transient network errors */ }
+ }
+
+ private void DescriptionBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ _draft.Description = DescriptionBox.Text;
+ var len = DescriptionBox.Text.Length;
+ var softCap = 2000;
+ DescCount.Text = $"{len} / {softCap}";
+
+ var overLimit = len > softCap;
+ DescCount.Foreground = overLimit
+ ? System.Windows.Media.Brushes.OrangeRed
+ : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
+
+ var trackBorder = DescCountBar.Parent as Border;
+ double trackWidth = trackBorder?.ActualWidth ?? 0;
+ if (trackWidth > 0)
+ DescCountBar.Width = Math.Min(trackWidth, trackWidth * (len / (double)softCap));
+
+ DescCountBar.Background = overLimit
+ ? System.Windows.Media.Brushes.OrangeRed
+ : new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0xF5, 0x9E, 0x0B)); // amber
+ }
+
+ private void CategoryBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Escape)
+ {
+ CategorySuggestionsList.Visibility = Visibility.Collapsed;
+ e.Handled = true;
+ }
+ }
+
+ private void CategorySuggestionsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (CategorySuggestionsList.SelectedItem is CategorySuggestion cat)
+ {
+ _draft.CategoryId = cat.CategoryId;
+ _draft.CategoryName = cat.CategoryName;
+ CategoryBox.Text = cat.CategoryName;
+ CategoryIdLabel.Text = $"ID: {cat.CategoryId}";
+ CategorySuggestionsList.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ // ---- Condition ----
+
+ private void ConditionBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ _draft.Condition = GetSelectedCondition();
+ }
+
+ private ItemCondition GetSelectedCondition()
+ {
+ var tag = (ConditionBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Used";
+ return tag switch
+ {
+ "New" => ItemCondition.New,
+ "OpenBox" => ItemCondition.OpenBox,
+ "Refurbished" => ItemCondition.Refurbished,
+ "ForParts" => ItemCondition.ForPartsOrNotWorking,
+ _ => ItemCondition.Used
+ };
+ }
+
+ // ---- Description ----
+
+ private async void AiDescription_Click(object sender, RoutedEventArgs e)
+ {
+ if (_aiService == null) return;
+ SetDescSpinner(true);
+ SetBusy(true, "Writing description...");
+ try
+ {
+ var description = await _aiService.WriteDescriptionAsync(
+ TitleBox.Text, GetSelectedCondition().ToString(), DescriptionBox.Text);
+ DescriptionBox.Text = description;
+ }
+ catch (Exception ex) { ShowError("AI Description", ex.Message); }
+ finally { SetBusy(false); SetDescSpinner(false); }
+ }
+
+ // ---- Price ----
+
+ private async void AiPrice_Click(object sender, RoutedEventArgs e)
+ {
+ if (_aiService == null) return;
+ SetPriceSpinner(true);
+ SetBusy(true, "Researching price...");
+ try
+ {
+ var result = await _aiService.SuggestPriceAsync(
+ TitleBox.Text, GetSelectedCondition().ToString());
+ PriceSuggestionText.Text = result;
+
+ // Extract price line for "Use this price"
+ var lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ var priceLine = lines.FirstOrDefault(l => l.StartsWith("PRICE:", StringComparison.OrdinalIgnoreCase));
+ _suggestedPriceValue = priceLine?.Replace("PRICE:", "", StringComparison.OrdinalIgnoreCase).Trim() ?? "";
+
+ PriceSuggestionPanel.Visibility = Visibility.Visible;
+ }
+ catch (Exception ex) { ShowError("AI Price", ex.Message); }
+ finally { SetBusy(false); SetPriceSpinner(false); }
+ }
+
+ private void UseSuggestedPrice_Click(object sender, RoutedEventArgs e)
+ {
+ if (decimal.TryParse(_suggestedPriceValue, out var price))
+ PriceBox.Value = (double)price;
+ }
+
+ // ---- Photos ----
+
+ private void Photos_DragOver(object sender, DragEventArgs e)
+ {
+ e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
+ ? DragDropEffects.Copy : DragDropEffects.None;
+ e.Handled = true;
+ }
+
+ private void Photos_Drop(object sender, DragEventArgs e)
+ {
+ if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
+ var files = (string[])e.Data.GetData(DataFormats.FileDrop);
+ // Remove highlight when drop completes
+ DropZone.BorderBrush = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray7");
+ DropZone.Background = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray10");
+ AddPhotos(files);
+ }
+
+ private void DropZone_DragEnter(object sender, DragEventArgs e)
+ {
+ if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
+ DropZone.BorderBrush = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
+ DropZone.Background = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(20, 0x5C, 0x6B, 0xC0)); // subtle indigo tint
+ }
+
+ private void DropZone_DragLeave(object sender, DragEventArgs e)
+ {
+ DropZone.BorderBrush = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray7");
+ DropZone.Background = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray10");
+ }
+
+ private void BrowsePhotos_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new OpenFileDialog
+ {
+ Title = "Select photos",
+ Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.bmp|All files|*.*",
+ Multiselect = true
+ };
+ if (dlg.ShowDialog() == true)
+ AddPhotos(dlg.FileNames);
+ }
+
+ private void AddPhotos(string[] paths)
+ {
+ var imageExts = new HashSet(StringComparer.OrdinalIgnoreCase)
+ { ".jpg", ".jpeg", ".png", ".gif", ".bmp" };
+
+ foreach (var path in paths)
+ {
+ if (!imageExts.Contains(Path.GetExtension(path))) continue;
+ if (_draft.PhotoPaths.Count >= 12) break;
+ if (_draft.PhotoPaths.Contains(path)) continue;
+
+ _draft.PhotoPaths.Add(path);
+ AddPhotoThumbnail(path);
+ }
+
+ UpdatePhotoPanel();
+ }
+
+ private void AddPhotoThumbnail(string path)
+ {
+ try
+ {
+ var bmp = new BitmapImage();
+ bmp.BeginInit();
+ bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
+ bmp.DecodePixelWidth = 128;
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.EndInit();
+ bmp.Freeze(); // M2
+
+ var img = new System.Windows.Controls.Image
+ {
+ Width = 72, Height = 72,
+ Stretch = System.Windows.Media.Stretch.UniformToFill,
+ Source = bmp,
+ ToolTip = Path.GetFileName(path)
+ };
+
+ // Rounded clip on the image
+ img.Clip = new System.Windows.Media.RectangleGeometry(
+ new Rect(0, 0, 72, 72), 4, 4);
+
+ // Remove button — shown on hover via opacity triggers
+ var removeBtn = new Button
+ {
+ Width = 18, Height = 18,
+ Cursor = Cursors.Hand,
+ ToolTip = "Remove photo",
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 2, 2, 0),
+ Padding = new Thickness(0),
+ Background = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(200, 30, 30, 30)),
+ Foreground = System.Windows.Media.Brushes.White,
+ BorderThickness = new Thickness(0),
+ FontSize = 11,
+ FontWeight = FontWeights.Bold,
+ Content = "✕",
+ Opacity = 0
+ };
+
+ // Container grid — shows remove button on mouse over
+ var container = new Grid
+ {
+ Width = 72, Height = 72,
+ Margin = new Thickness(4),
+ Cursor = Cursors.Hand
+ };
+ container.Children.Add(img);
+ container.Children.Add(removeBtn);
+
+ container.MouseEnter += (s, e) => removeBtn.Opacity = 1;
+ container.MouseLeave += (s, e) => removeBtn.Opacity = 0;
+ removeBtn.Click += (s, e) => RemovePhoto(path, container);
+
+ PhotosPanel.Children.Add(container);
+ }
+ catch { /* skip unreadable files */ }
+ }
+
+ private void RemovePhoto(string path, UIElement thumb)
+ {
+ _draft.PhotoPaths.Remove(path);
+ PhotosPanel.Children.Remove(thumb);
+ UpdatePhotoPanel();
+ }
+
+ private void UpdatePhotoPanel()
+ {
+ var count = _draft.PhotoPaths.Count;
+ DropHint.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ PhotoCountBadge.Text = count.ToString();
+ // Tint the badge red when at the limit
+ PhotoCountBadge.Foreground = count >= 12
+ ? System.Windows.Media.Brushes.OrangeRed
+ : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray2");
+ }
+
+ private void ClearPhotos_Click(object sender, RoutedEventArgs e)
+ {
+ _draft.PhotoPaths.Clear();
+ PhotosPanel.Children.Clear();
+ UpdatePhotoPanel();
+ }
+
+ // ---- Post / Save ----
+
+ private async void PostListing_Click(object sender, RoutedEventArgs e)
+ {
+ if (!ValidateDraft()) return;
+
+ _draft.Title = TitleBox.Text.Trim();
+ _draft.Description = DescriptionBox.Text.Trim();
+ _draft.Price = (decimal)(PriceBox.Value ?? 0);
+ _draft.Quantity = (int)(QuantityBox.Value ?? 1);
+ _draft.Condition = GetSelectedCondition();
+ _draft.Format = FormatBox.SelectedIndex == 0 ? ListingFormat.FixedPrice : ListingFormat.Auction;
+ _draft.Postcode = PostcodeBox.Text;
+
+ SetPostSpinner(true);
+ SetBusy(true, "Posting to eBay...");
+ PostBtn.IsEnabled = false;
+
+ try
+ {
+ var url = await _listingService!.PostListingAsync(_draft);
+ ListingUrlText.Text = url;
+ SuccessPanel.Visibility = Visibility.Visible;
+ GetWindow()?.SetStatus($"Listed: {_draft.Title}");
+ }
+ catch (Exception ex)
+ {
+ ShowError("Post Failed", ex.Message);
+ }
+ finally
+ {
+ SetBusy(false);
+ SetPostSpinner(false);
+ PostBtn.IsEnabled = true;
+ }
+ }
+
+ private void ListingUrl_Click(object sender, MouseButtonEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(_draft.EbayListingUrl))
+ Process.Start(new ProcessStartInfo(_draft.EbayListingUrl) { UseShellExecute = true });
+ }
+
+ private void CopyUrl_Click(object sender, RoutedEventArgs e)
+ {
+ var url = ListingUrlText.Text;
+ if (!string.IsNullOrEmpty(url))
+ System.Windows.Clipboard.SetText(url);
+ }
+
+ private void CopyTitle_Click(object sender, RoutedEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(_draft.Title))
+ System.Windows.Clipboard.SetText(_draft.Title);
+ }
+
+ private void SaveDraft_Click(object sender, RoutedEventArgs e)
+ {
+ // Drafts: future feature — for now just confirm save
+ MessageBox.Show("Draft saved (local save to be implemented in a future update).",
+ "Save Draft", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private void NewListing_Click(object sender, RoutedEventArgs e)
+ {
+ if (!string.IsNullOrWhiteSpace(TitleBox.Text))
+ {
+ var result = MessageBox.Show(
+ "Start a new listing? Current details will be lost.",
+ "New Listing",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Question);
+ if (result != MessageBoxResult.OK) return;
+ }
+
+ _draft = new ListingDraft { Postcode = PostcodeBox.Text };
+ TitleBox.Text = "";
+ DescriptionBox.Text = "";
+ CategoryBox.Text = "";
+ CategoryIdLabel.Text = "(no category)";
+ PriceBox.Value = 0;
+ QuantityBox.Value = 1;
+ ConditionBox.SelectedIndex = 3; // Used
+ FormatBox.SelectedIndex = 0;
+ PhotosPanel.Children.Clear();
+ UpdatePhotoPanel();
+ SuccessPanel.Visibility = Visibility.Collapsed;
+ PriceSuggestionPanel.Visibility = Visibility.Collapsed;
+ }
+
+ // ---- Helpers ----
+
+ private bool ValidateDraft()
+ {
+ if (string.IsNullOrWhiteSpace(TitleBox.Text))
+ { ShowError("Validation", "Please enter a title."); return false; }
+ if (TitleBox.Text.Length > 80)
+ { ShowError("Validation", "Title must be 80 characters or fewer."); return false; }
+ if (string.IsNullOrEmpty(_draft.CategoryId))
+ { ShowError("Validation", "Please select a category."); return false; }
+ if ((PriceBox.Value ?? 0) <= 0)
+ { ShowError("Validation", "Please enter a price greater than zero."); return false; }
+ return true;
+ }
+
+ private void SetBusy(bool busy, string message = "")
+ {
+ IsEnabled = !busy;
+ GetWindow()?.SetStatus(busy ? message : "Ready");
+ }
+
+ private void SetPostSpinner(bool spinning)
+ {
+ PostSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
+ PostIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void SetTitleSpinner(bool spinning)
+ {
+ TitleSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
+ TitleAiIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void SetDescSpinner(bool spinning)
+ {
+ DescSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
+ DescAiIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void SetPriceSpinner(bool spinning)
+ {
+ PriceSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
+ PriceAiIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ private void ShowError(string title, string message)
+ => MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Warning);
+
+ private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
+}
diff --git a/EbayListingTool/appsettings.json b/EbayListingTool/appsettings.json
new file mode 100644
index 0000000..c721c73
--- /dev/null
+++ b/EbayListingTool/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Ebay": {
+ "ClientId": "YOUR_EBAY_CLIENT_ID",
+ "ClientSecret": "YOUR_EBAY_CLIENT_SECRET",
+ "RuName": "YOUR_EBAY_RUNAME",
+ "Sandbox": true,
+ "RedirectPort": 8080,
+ "DefaultPostcode": "NR1 1AA"
+ },
+ "OpenRouter": {
+ "ApiKey": "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",
+ "Model": "anthropic/claude-sonnet-4-5"
+ }
+}
diff --git a/EbayListingTool/sample-import.csv b/EbayListingTool/sample-import.csv
new file mode 100644
index 0000000..56a755c
--- /dev/null
+++ b/EbayListingTool/sample-import.csv
@@ -0,0 +1,4 @@
+Title,Description,Price,Condition,CategoryKeyword,Quantity,PhotoPaths
+"Sony PlayStation 4 Console 500GB","Sony PS4 500GB console in good working order. Comes with one controller and power/HDMI cables. Light scratches on top.",89.99,Used,Video Game Consoles,1,
+"Apple iPhone 12 64GB Black","Apple iPhone 12 64GB in black. Unlocked to all networks. Screen has no cracks. Battery health 87%. Charger not included.",179.00,Used,Mobile Phones,1,
+"LEGO Star Wars Millennium Falcon 75257","Complete set, all pieces present, built once. Box included, instructions included.",55.00,Used,Construction Toys,1,