commit 9fad0f2ac0d4985280c3e7c8d9c3b4e008dd5730 Author: Peter Foster Date: Mon Apr 13 17:33:27 2026 +0100 Initial commit: EbayListingTool WPF application C# WPF desktop app for creating eBay UK listings with AI-powered photo analysis. Features: multi-photo vision analysis via OpenRouter (Claude), local listing save/export, saved listings browser, single item listing form, bulk import from CSV/Excel, and eBay OAuth authentication. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..587ac90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Build outputs +bin/ +obj/ +*.user +*.suo +.vs/ + +# Config with secrets — never commit +EbayListingTool/appsettings.json + +# Rider / JetBrains +.idea/ + +# OS +.DS_Store +Thumbs.db 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..e0879bc --- /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..f4539ad --- /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..33b4260 --- /dev/null +++ b/EbayListingTool/Views/PhotoAnalysisView.xaml @@ -0,0 +1,629 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..cfcf36e --- /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/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,