From d9072a6018eda8129fb88c39032722dae2e84a43 Mon Sep 17 00:00:00 2001 From: Peter Foster Date: Fri, 17 Apr 2026 12:59:15 +0100 Subject: [PATCH] fix: Content-Language header on offer creation; double photo thumbnails; sync Windows diverged files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Content-Language: en-US to CreateOfferAsync (eBay Inventory API requires it) - Double thumbnail sizes in NewListingView (96→192, 100→200) - Sync NewListingView.xaml/.cs from Windows (replaces SingleItemView) - Sync latest AiAssistantService, SavedListingsService, MainWindow, SavedListingsView from Windows Co-Authored-By: Claude Sonnet 4.6 --- EbayListingTool/Models/PhotoAnalysisResult.cs | 6 +- EbayListingTool/Models/SavedListing.cs | 43 +- .../Services/AiAssistantService.cs | 61 +- .../Services/EbayListingService.cs | 1 + .../Services/PriceLookupService.cs | 8 +- .../Services/SavedListingsService.cs | 84 +- EbayListingTool/Views/MainWindow.xaml | 206 ++-- EbayListingTool/Views/MainWindow.xaml.cs | 92 +- EbayListingTool/Views/NewListingView.xaml | 538 ++++++++++ EbayListingTool/Views/NewListingView.xaml.cs | 784 ++++++++++++++ EbayListingTool/Views/SavedListingsView.xaml | 223 ++-- .../Views/SavedListingsView.xaml.cs | 172 ++-- EbayListingTool/Views/SingleItemView.xaml.cs | 956 ------------------ 13 files changed, 1729 insertions(+), 1445 deletions(-) create mode 100644 EbayListingTool/Views/NewListingView.xaml create mode 100644 EbayListingTool/Views/NewListingView.xaml.cs delete mode 100644 EbayListingTool/Views/SingleItemView.xaml.cs diff --git a/EbayListingTool/Models/PhotoAnalysisResult.cs b/EbayListingTool/Models/PhotoAnalysisResult.cs index 4c3028a..c75f737 100644 --- a/EbayListingTool/Models/PhotoAnalysisResult.cs +++ b/EbayListingTool/Models/PhotoAnalysisResult.cs @@ -1,4 +1,4 @@ -namespace EbayListingTool.Models; +namespace EbayListingTool.Models; public class PhotoAnalysisResult { @@ -18,6 +18,6 @@ public class PhotoAnalysisResult public string PriceRangeDisplay => PriceMin > 0 && PriceMax > 0 - ? $"£{PriceMin:F2} – £{PriceMax:F2} (suggested £{PriceSuggested:F2})" - : PriceSuggested > 0 ? $"£{PriceSuggested:F2}" : ""; + ? $"\u00A3{PriceMin:F2} – \u00A3{PriceMax:F2} (suggested \u00A3{PriceSuggested:F2})" + : PriceSuggested > 0 ? $"\u00A3{PriceSuggested:F2}" : ""; } diff --git a/EbayListingTool/Models/SavedListing.cs b/EbayListingTool/Models/SavedListing.cs index 429416b..df9d472 100644 --- a/EbayListingTool/Models/SavedListing.cs +++ b/EbayListingTool/Models/SavedListing.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using EbayListingTool.Helpers; - -namespace EbayListingTool.Models; +namespace EbayListingTool.Models; public class SavedListing { @@ -12,23 +9,43 @@ public class SavedListing public decimal Price { get; set; } public string Category { get; set; } = ""; public string ConditionNotes { get; set; } = ""; - public string CategoryId { get; set; } = ""; - public ItemCondition Condition { get; set; } = ItemCondition.Used; - public PostageOption Postage { get; set; } = PostageOption.RoyalMailTracked48; - public decimal ShippingCost { get; set; } - public Dictionary Aspects { get; set; } = new(); public string ExportFolder { get; set; } = ""; + /// eBay category ID — stored at save time so we can post without re-looking it up. + public string CategoryId { get; set; } = ""; + + /// Item condition — defaults to Used for existing records without this field. + public ItemCondition Condition { get; set; } = ItemCondition.Used; + + /// Listing format — defaults to FixedPrice for existing records. + public ListingFormat Format { get; set; } = ListingFormat.FixedPrice; + + /// Seller postcode — populated from appsettings default at save time. + public string Postcode { 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 PriceWords => NumberWords.ToVerbalPrice(Price); + public string PriceDisplay => Price > 0 ? $"\u00A3{Price:F2}" : "—"; public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm"); - public string SavedAtRelative => NumberWords.ToRelativeDate(SavedAt); + /// + /// Converts this saved draft back into a ListingDraft suitable for PostListingAsync. + /// + public ListingDraft ToListingDraft() => new ListingDraft + { + Title = Title, + Description = Description, + Price = Price, + CategoryId = CategoryId, + CategoryName = Category, + Condition = Condition, + Format = Format, + Postcode = Postcode, + PhotoPaths = new List(PhotoPaths), + Quantity = 1 + }; } diff --git a/EbayListingTool/Services/AiAssistantService.cs b/EbayListingTool/Services/AiAssistantService.cs index 519ffee..0b6cfb8 100644 --- a/EbayListingTool/Services/AiAssistantService.cs +++ b/EbayListingTool/Services/AiAssistantService.cs @@ -1,4 +1,4 @@ -using System.Net.Http; +using System.Net.Http; using System.Net.Http.Headers; using System.Text; using EbayListingTool.Models; @@ -65,7 +65,7 @@ public class AiAssistantService string priceContext = ""; if (soldPrices != null && soldPrices.Any()) { - var prices = soldPrices.Select(p => $"£{p:F2}"); + var prices = soldPrices.Select(p => $"\u00A3{p:F2}"); priceContext = $"\nRecent eBay UK sold prices for similar items: {string.Join(", ", prices)}"; } @@ -143,7 +143,7 @@ public class AiAssistantService RefineWithCorrectionsAsync(string title, string description, decimal price, string corrections) { var priceContext = price > 0 - ? $"Current price: £{price:F2}\n\n" + ? $"Current price: \u00A3{price:F2}\n\n" : ""; var prompt = @@ -246,7 +246,7 @@ public class AiAssistantService " \"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)."; + "price_suggested should be a good Buy It Now price. Use GBP numbers only (no \u00A3 symbol)."; var json = await CallWithVisionAsync(dataUrls, prompt); @@ -284,59 +284,6 @@ public class AiAssistantService } } - public async Task> SuggestAspectsAsync( - string title, string description, List aspects) - { - if (aspects.Count == 0) return new(); - - var aspectLines = aspects.Select(a => - { - var line = $"- {a.Name} (required: {a.IsRequired})"; - if (!a.IsFreeText && a.AllowedValues.Count > 0) - line += $" — allowed values: {string.Join(", ", a.AllowedValues.Take(10))}"; - return line; - }); - - var prompt = - "You are an eBay UK listing expert. Based on this listing, suggest values for the " + - "eBay item specifics (aspects) listed below.\n\n" + - $"Title: {title}\n" + - $"Description: {description}\n\n" + - "Aspects needed:\n" + - string.Join("\n", aspectLines) + "\n\n" + - "Rules:\n" + - "- For aspects with 'allowed values', you MUST use one of those exact values.\n" + - "- If unsure about an aspect, omit it from the response rather than guess.\n" + - "- Return ONLY valid JSON — no markdown, no explanation:\n" + - "{\n" + - " \"Brand\": \"Apple\",\n" + - " \"Model\": \"iPhone 14\"\n" + - "}\n" + - "Use the exact aspect name as the key."; - - var json = await CallAsync(prompt); - - try - { - JObject obj; - try { obj = JObject.Parse(json.Trim()); } - catch (JsonReaderException) - { - var m = System.Text.RegularExpressions.Regex.Match( - json, @"```(?:json)?\s*(\{[\s\S]*?\})\s*```"); - obj = JObject.Parse(m.Success ? m.Groups[1].Value : json.Trim()); - } - - return obj.Properties() - .Where(p => !string.IsNullOrEmpty(p.Value?.ToString())) - .ToDictionary(p => p.Name, p => p.Value!.ToString()); - } - catch - { - return new(); - } - } - private async Task CallWithVisionAsync(IEnumerable imageDataUrls, string textPrompt) { if (string.IsNullOrEmpty(_apiKey)) diff --git a/EbayListingTool/Services/EbayListingService.cs b/EbayListingTool/Services/EbayListingService.cs index c98894f..e715fa4 100644 --- a/EbayListingTool/Services/EbayListingService.cs +++ b/EbayListingTool/Services/EbayListingService.cs @@ -375,6 +375,7 @@ public class EbayListingService using var req = MakeRequest(HttpMethod.Post, $"{_auth.BaseUrl}/sell/inventory/v1/offer", token); req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Content.Headers.Add("Content-Language", "en-US"); var res = await _http.SendAsync(req); var responseJson = await res.Content.ReadAsStringAsync(); diff --git a/EbayListingTool/Services/PriceLookupService.cs b/EbayListingTool/Services/PriceLookupService.cs index 8c03023..d4c1ff6 100644 --- a/EbayListingTool/Services/PriceLookupService.cs +++ b/EbayListingTool/Services/PriceLookupService.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using EbayListingTool.Models; namespace EbayListingTool.Services; @@ -39,7 +39,7 @@ public class PriceLookupService return new PriceSuggestion( result.Suggested, "ebay", - $"eBay suggests £{result.Suggested:F2} (from {result.Count} listings)"); + $"eBay suggests \u00A3{result.Suggested:F2} (from {result.Count} listings)"); } catch { /* eBay unavailable — fall through */ } @@ -58,7 +58,7 @@ public class PriceLookupService return new PriceSuggestion( avg, "history", - $"Your avg for {listing.Category}: £{avg:F2} ({sameCat.Count} listings)"); + $"Your avg for {listing.Category}: \u00A3{avg:F2} ({sameCat.Count} listings)"); } // 3. AI estimate @@ -73,7 +73,7 @@ public class PriceLookupService out var price) && price > 0) { - return new PriceSuggestion(price, "ai", $"AI estimate: £{price:F2}"); + return new PriceSuggestion(price, "ai", $"AI estimate: \u00A3{price:F2}"); } } catch { /* AI unavailable */ } diff --git a/EbayListingTool/Services/SavedListingsService.cs b/EbayListingTool/Services/SavedListingsService.cs index bf403f1..0184b4a 100644 --- a/EbayListingTool/Services/SavedListingsService.cs +++ b/EbayListingTool/Services/SavedListingsService.cs @@ -1,4 +1,4 @@ -using EbayListingTool.Models; +using EbayListingTool.Models; using Newtonsoft.Json; namespace EbayListingTool.Services; @@ -35,7 +35,11 @@ public class SavedListingsService public (SavedListing Listing, int SkippedPhotos) Save( string title, string description, decimal price, string category, string conditionNotes, - IEnumerable sourcePaths) + IEnumerable sourcePaths, + string categoryId = "", + ItemCondition condition = ItemCondition.Used, + ListingFormat format = ListingFormat.FixedPrice, + string postcode = "") { var safeName = MakeSafeFilename(title); var exportDir = UniqueDir(Path.Combine(ExportsDir, safeName)); @@ -68,6 +72,10 @@ public class SavedListingsService Description = description, Price = price, Category = category, + CategoryId = categoryId, + Condition = condition, + Format = format, + Postcode = postcode, ConditionNotes = conditionNotes, ExportFolder = exportDir, PhotoPaths = photoPaths @@ -78,76 +86,6 @@ public class SavedListingsService return (listing, sources.Count - photoPaths.Count); } - /// - /// Saves a fully-posted listing draft, preserving aspects, postage, and shipping cost. - /// - public (SavedListing Listing, int SkippedPhotos) Save(ListingDraft draft) - { - return SaveFull( - draft.Title, - draft.Description, - draft.Price, - draft.CategoryName, - draft.CategoryId, - draft.Condition, - draft.Postage, - draft.ShippingCost, - draft.Aspects, - string.Empty, - draft.PhotoPaths - ); - } - - private (SavedListing Listing, int SkippedPhotos) SaveFull( - string title, string description, decimal price, - string category, string categoryId, - ItemCondition condition, PostageOption postage, decimal shippingCost, - Dictionary aspects, - string conditionNotes, - IEnumerable sourcePaths) - { - var safeName = MakeSafeFilename(title); - var exportDir = UniqueDir(Path.Combine(ExportsDir, safeName)); - Directory.CreateDirectory(exportDir); - - 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; - 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); - } - - 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, - CategoryId = categoryId, - Condition = condition, - Postage = postage, - ShippingCost = shippingCost, - Aspects = new Dictionary(aspects), - ConditionNotes = conditionNotes, - ExportFolder = exportDir, - PhotoPaths = photoPaths - }; - - _listings.Insert(0, listing); - Persist(); - return (listing, sources.Count - photoPaths.Count); - } - public void Delete(SavedListing listing) { _listings.Remove(listing); @@ -258,7 +196,7 @@ public class SavedListingsService var sb = new System.Text.StringBuilder(); sb.AppendLine($"Title: {title}"); sb.AppendLine($"Category: {category}"); - sb.AppendLine($"Price: £{price:F2}"); + sb.AppendLine($"Price: \u00A3{price:F2}"); if (!string.IsNullOrWhiteSpace(conditionNotes)) sb.AppendLine($"Condition: {conditionNotes}"); sb.AppendLine(); diff --git a/EbayListingTool/Views/MainWindow.xaml b/EbayListingTool/Views/MainWindow.xaml index 7c43bb2..fb111e2 100644 --- a/EbayListingTool/Views/MainWindow.xaml +++ b/EbayListingTool/Views/MainWindow.xaml @@ -1,13 +1,14 @@ - @@ -59,105 +60,62 @@ - - - - - - - - - - - - - - - + + + + - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - - + + - - + + @@ -167,20 +125,21 @@ + VerticalAlignment="Center" + /> - - - - - - - - @@ -271,7 +178,8 @@ + Foreground="{DynamicResource MahApps.Brushes.Gray5}" + /> @@ -282,6 +190,14 @@ + + diff --git a/EbayListingTool/Views/MainWindow.xaml.cs b/EbayListingTool/Views/MainWindow.xaml.cs index 50d24c2..767e45e 100644 --- a/EbayListingTool/Views/MainWindow.xaml.cs +++ b/EbayListingTool/Views/MainWindow.xaml.cs @@ -12,44 +12,32 @@ public partial class MainWindow : MetroWindow private readonly EbayListingService _listingService; private readonly EbayCategoryService _categoryService; private readonly AiAssistantService _aiService; - private readonly EbayAspectsService _aspectsService; private readonly BulkImportService _bulkService; private readonly SavedListingsService _savedService; private readonly EbayPriceResearchService _priceService; - private readonly PriceLookupService _priceLookupService; + private readonly PriceLookupService _priceLookupService; public MainWindow() { InitializeComponent(); var config = App.Configuration; - _auth = new EbayAuthService(config); - _categoryService = new EbayCategoryService(_auth); - _listingService = new EbayListingService(_auth, _categoryService); - _aiService = new AiAssistantService(config); - _aspectsService = new EbayAspectsService(_auth); - _bulkService = new BulkImportService(); - _savedService = new SavedListingsService(); + _auth = new EbayAuthService(config); + _categoryService = new EbayCategoryService(_auth); + _listingService = new EbayListingService(_auth, _categoryService); + _aiService = new AiAssistantService(config); + _bulkService = new BulkImportService(); + _savedService = new SavedListingsService(); _priceService = new EbayPriceResearchService(_auth); _priceLookupService = new PriceLookupService(_priceService, _savedService, _aiService); - // Photo Analysis tab — no eBay needed - PhotoView.Initialise(_aiService, _savedService, _priceService); - PhotoView.UseDetailsRequested += OnUseDetailsRequested; + var defaultPostcode = config["Ebay:DefaultPostcode"] ?? ""; - // Saved Listings tab - SavedView.Initialise(_savedService, _priceLookupService); - SavedView.RelistRequested += listing => - { - SingleView.PopulateFromSavedListing(listing); - SwitchToNewListingTab(); - }; + NewListingView.Initialise(_listingService, _categoryService, _aiService, _auth, + _savedService, defaultPostcode); - // New Listing + Bulk tabs - SingleView.Initialise(_listingService, _categoryService, _aiService, _auth, _aspectsService, _savedService); - BulkView.Initialise(_listingService, _categoryService, _aiService, _bulkService, _auth); + SavedView.Initialise(_savedService, _priceLookupService, _listingService, _auth); - // Try to restore saved eBay session _auth.TryLoadSavedToken(); UpdateConnectionState(); } @@ -59,7 +47,7 @@ public partial class MainWindow : MetroWindow private async void ConnectBtn_Click(object sender, RoutedEventArgs e) { ConnectBtn.IsEnabled = false; - SetStatus("Connecting to eBay…"); + SetStatus("Connecting to eBay..."); try { var username = await _auth.LoginAsync(); @@ -75,14 +63,14 @@ public partial class MainWindow : MetroWindow finally { ConnectBtn.IsEnabled = true; - UpdateConnectionState(); // always sync UI to actual auth state + UpdateConnectionState(); } } private void DisconnectBtn_Click(object sender, RoutedEventArgs e) { _auth.Disconnect(); - _listingService.ClearCache(); // clear cached policy/location IDs for next login + _listingService.ClearCache(); UpdateConnectionState(); SetStatus("Disconnected from eBay."); } @@ -90,50 +78,48 @@ public partial class MainWindow : MetroWindow 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}"; + StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen); + StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}"; StatusBarEbay.Foreground = new SolidColorBrush(Colors.LimeGreen); + DisconnectBtn.Visibility = Visibility.Visible; } 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"; + StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88)); + StatusBarEbay.Text = "eBay: disconnected"; StatusBarEbay.Foreground = (Brush)FindResource("MahApps.Brushes.Gray5"); + DisconnectBtn.Visibility = Visibility.Collapsed; } } - // ---- Photo Analysis → New Listing handoff ---- + // ---- File menu ---- - private void OnUseDetailsRequested(PhotoAnalysisResult result, IReadOnlyList photoPaths, decimal price) + private void BulkImport_Click(object sender, RoutedEventArgs e) { - SingleView.PopulateFromAnalysis(result, photoPaths, price); // Q1: forward all photos + if (!_auth.IsConnected) + { + MessageBox.Show("Please connect to eBay before using Bulk Import.", + "Not Connected", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + var win = new BulkImportWindow(_listingService, _categoryService, _aiService, _bulkService, _auth); + win.Owner = this; + win.ShowDialog(); } - public void SwitchToNewListingTab() - { - MainTabs.SelectedItem = NewListingTab; - } + private void Exit_Click(object sender, RoutedEventArgs e) => Close(); - public void RefreshSavedListings() - { - SavedView.RefreshList(); - } - - // ---- Helpers ---- + // ---- Public interface for child views ---- public void SetStatus(string message) => StatusBar.Text = message; + + public void SwitchToNewListingTab() => MainTabs.SelectedItem = NewListingTab; + + public void RefreshDrafts() => SavedView.RefreshList(); + + public void RefreshSavedListings() => RefreshDrafts(); // backwards compat for NewListingView } diff --git a/EbayListingTool/Views/NewListingView.xaml b/EbayListingTool/Views/NewListingView.xaml new file mode 100644 index 0000000..e12f2a5 --- /dev/null +++ b/EbayListingTool/Views/NewListingView.xaml @@ -0,0 +1,538 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EbayListingTool/Views/NewListingView.xaml.cs b/EbayListingTool/Views/NewListingView.xaml.cs new file mode 100644 index 0000000..264dae7 --- /dev/null +++ b/EbayListingTool/Views/NewListingView.xaml.cs @@ -0,0 +1,784 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using EbayListingTool.Models; +using EbayListingTool.Services; +using Microsoft.Win32; + +namespace EbayListingTool.Views; + +public partial class NewListingView : UserControl +{ + // Services (injected via Initialise) + private EbayListingService? _listingService; + private EbayCategoryService? _categoryService; + private AiAssistantService? _aiService; + private EbayAuthService? _auth; + private SavedListingsService? _savedService; + private string _defaultPostcode = ""; + + // State A — photos + private readonly List _photoPaths = new(); + private const int MaxPhotos = 12; + + // State B — draft being edited + private ListingDraft _draft = new(); + private PhotoAnalysisResult? _lastAnalysis; + private bool _suppressCategoryLookup; + private System.Threading.CancellationTokenSource? _categoryCts; + private string _suggestedPriceValue = ""; + + // Loading step cycling + private readonly DispatcherTimer _loadingTimer; + private int _loadingStep; + private static readonly string[] LoadingSteps = + [ + "Examining the photo\u2026", + "Identifying the item\u2026", + "Researching eBay prices\u2026", + "Writing description\u2026" + ]; + + public NewListingView() + { + InitializeComponent(); + _loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.5) }; + _loadingTimer.Tick += (_, _) => + { + _loadingStep = (_loadingStep + 1) % LoadingSteps.Length; + LoadingStepText.Text = LoadingSteps[_loadingStep]; + }; + } + + private void UserControl_Loaded(object sender, RoutedEventArgs e) { } + + public void Initialise(EbayListingService listingService, EbayCategoryService categoryService, + AiAssistantService aiService, EbayAuthService auth, + SavedListingsService savedService, string defaultPostcode) + { + _listingService = listingService; + _categoryService = categoryService; + _aiService = aiService; + _auth = auth; + _savedService = savedService; + _defaultPostcode = defaultPostcode; + } + + // ---- State machine ---- + + private enum ListingState { DropZone, ReviewEdit, Success } + + private void SetState(ListingState state) + { + StateA.Visibility = state == ListingState.DropZone ? Visibility.Visible : Visibility.Collapsed; + StateB.Visibility = state == ListingState.ReviewEdit ? Visibility.Visible : Visibility.Collapsed; + StateC.Visibility = state == ListingState.Success ? Visibility.Visible : Visibility.Collapsed; + } + + // ---- State A: Drop zone ---- + + private void DropZone_DragOver(object sender, DragEventArgs e) + { + e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) + ? DragDropEffects.Copy : DragDropEffects.None; + e.Handled = true; + } + + private void DropZone_DragEnter(object sender, DragEventArgs e) + { + DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent"); + e.Handled = true; + } + + private void DropZone_DragLeave(object sender, DragEventArgs e) + { + DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray6"); + } + + private void DropZone_Drop(object sender, DragEventArgs e) + { + DropZone_DragLeave(sender, e); + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; + var files = (string[])e.Data.GetData(DataFormats.FileDrop); + AddPhotos(files.Where(IsImageFile).ToArray()); + } + + private void DropZone_Click(object sender, MouseButtonEventArgs e) + { + var dlg = new OpenFileDialog + { + Title = "Select photos of the item", + Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*", + Multiselect = true + }; + if (dlg.ShowDialog() == true) + AddPhotos(dlg.FileNames); + } + + private void AddPhotos(string[] paths) + { + foreach (var path in paths) + { + if (_photoPaths.Count >= MaxPhotos) break; + if (_photoPaths.Contains(path)) continue; + if (!IsImageFile(path)) continue; + _photoPaths.Add(path); + } + UpdateThumbStrip(); + UpdateAnalyseButton(); + } + + private void UpdateThumbStrip() + { + ThumbStrip.Children.Clear(); + ThumbScroller.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + foreach (var path in _photoPaths) + { + try + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.UriSource = new Uri(path, UriKind.Absolute); + bmp.DecodePixelWidth = 240; + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.EndInit(); + bmp.Freeze(); + + var img = new Image + { + Source = bmp, Width = 192, Height = 192, + Stretch = System.Windows.Media.Stretch.UniformToFill, + Margin = new Thickness(4) + }; + img.Clip = new System.Windows.Media.RectangleGeometry( + new Rect(0, 0, 192, 192), 6, 6); + ThumbStrip.Children.Add(img); + } + catch { /* skip bad files */ } + } + + PhotoCountLabel.Text = $"{_photoPaths.Count} / {MaxPhotos} photos"; + PhotoCountLabel.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + private void UpdateAnalyseButton() + { + AnalyseBtn.IsEnabled = _photoPaths.Count > 0; + } + + private async void Analyse_Click(object sender, RoutedEventArgs e) + { + if (_aiService == null || _photoPaths.Count == 0) return; + SetAnalysing(true); + try + { + var result = await _aiService.AnalyseItemFromPhotosAsync(_photoPaths); + _lastAnalysis = result; + await PopulateStateBAsync(result); + SetState(ListingState.ReviewEdit); + } + catch (Exception ex) + { + MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error", + MessageBoxButton.OK, MessageBoxImage.Warning); + } + finally + { + SetAnalysing(false); + } + } + + private void SetAnalysing(bool busy) + { + AnalyseBtn.IsEnabled = !busy; + AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible; + AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI"; + LoadingPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + DropZoneBorder.Visibility = busy ? Visibility.Collapsed : Visibility.Visible; + if (busy) + { + _loadingStep = 0; + LoadingStepText.Text = LoadingSteps[0]; + _loadingTimer.Start(); + } + else + { + _loadingTimer.Stop(); + } + } + + // ---- State B: Populate from analysis ---- + + private async Task PopulateStateBAsync(PhotoAnalysisResult result) + { + _draft = new ListingDraft { Postcode = _defaultPostcode }; + _draft.PhotoPaths = new List(_photoPaths); + RebuildBPhotoThumbnails(); + + BTitleBox.Text = result.Title; + BDescBox.Text = result.Description; + BPriceBox.Value = (double)Math.Round(result.PriceSuggested, 2); + BPostcodeBox.Text = _defaultPostcode; + BConditionBox.SelectedIndex = 3; // Used + + if (!string.IsNullOrWhiteSpace(result.CategoryKeyword)) + await AutoFillCategoryAsync(result.CategoryKeyword); + + if (result.PriceMin > 0 && result.PriceMax > 0) + { + BPriceHint.Text = $"AI estimate: \u00A3{result.PriceMin:F2} – \u00A3{result.PriceMax:F2}"; + BPriceHint.Visibility = Visibility.Visible; + } + } + + // ---- Title ---- + + private void TitleBox_TextChanged(object sender, TextChangedEventArgs e) + { + _draft.Title = BTitleBox.Text; + var len = BTitleBox.Text.Length; + BTitleCount.Text = $"{len} / 80"; + var over = len > 75; + var trackBorder = BTitleBar.Parent as Border; + double trackWidth = trackBorder?.ActualWidth ?? 0; + if (trackWidth > 0) BTitleBar.Width = trackWidth * (len / 80.0); + BTitleBar.Background = over + ? System.Windows.Media.Brushes.OrangeRed + : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent"); + BTitleCount.Foreground = over + ? System.Windows.Media.Brushes.OrangeRed + : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5"); + } + + private async void AiTitle_Click(object sender, RoutedEventArgs e) + { + if (_aiService == null) return; + SetTitleBusy(true); + try + { + var title = await _aiService.GenerateTitleAsync(BTitleBox.Text, GetSelectedCondition().ToString()); + BTitleBox.Text = title.Trim().TrimEnd('.').Trim('"'); + if (string.IsNullOrWhiteSpace(_draft.CategoryId)) + await AutoFillCategoryAsync(BTitleBox.Text); + } + catch (Exception ex) { ShowError("AI Title", ex.Message); } + finally { SetTitleBusy(false); } + } + + private void SetTitleBusy(bool busy) + { + AiTitleBtn.IsEnabled = !busy; + TitleSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + TitleAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible; + } + + // ---- Description ---- + + private void DescBox_TextChanged(object sender, TextChangedEventArgs e) + { + _draft.Description = BDescBox.Text; + var len = BDescBox.Text.Length; + const int softCap = 2000; + BDescCount.Text = $"{len} / {softCap}"; + var over = len > softCap; + var trackBorder = BDescBar.Parent as Border; + double trackWidth = trackBorder?.ActualWidth ?? 0; + if (trackWidth > 0) BDescBar.Width = Math.Min(trackWidth, trackWidth * (len / (double)softCap)); + BDescBar.Background = over + ? System.Windows.Media.Brushes.OrangeRed + : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xF5, 0x9E, 0x0B)); + BDescCount.Foreground = over + ? System.Windows.Media.Brushes.OrangeRed + : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5"); + } + + private async void AiDesc_Click(object sender, RoutedEventArgs e) + { + if (_aiService == null) return; + SetDescBusy(true); + try + { + var desc = await _aiService.WriteDescriptionAsync( + BTitleBox.Text, GetSelectedCondition().ToString(), BDescBox.Text); + BDescBox.Text = desc; + } + catch (Exception ex) { ShowError("AI Description", ex.Message); } + finally { SetDescBusy(false); } + } + + private void SetDescBusy(bool busy) + { + AiDescBtn.IsEnabled = !busy; + DescSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + DescAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible; + } + + // ---- Category ---- + + private void CategoryBox_TextChanged(object sender, TextChangedEventArgs e) + { + if (_suppressCategoryLookup) return; + _categoryCts?.Cancel(); + _categoryCts?.Dispose(); + _categoryCts = new System.Threading.CancellationTokenSource(); + var cts = _categoryCts; + if (BCategoryBox.Text.Length < 3) { BCategoryList.Visibility = Visibility.Collapsed; return; } + _ = SearchCategoryAsync(BCategoryBox.Text, cts); + } + + private async Task SearchCategoryAsync(string text, System.Threading.CancellationTokenSource cts) + { + try + { + await Task.Delay(350, cts.Token); + if (cts.IsCancellationRequested) return; + var suggestions = await _categoryService!.GetCategorySuggestionsAsync(text); + if (cts.IsCancellationRequested) return; + BCategoryList.ItemsSource = suggestions.Select(s => s.CategoryName).ToList(); + BCategoryList.Tag = suggestions; + BCategoryList.Visibility = suggestions.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + } + catch (OperationCanceledException) { } + catch { } + } + + private void CategoryBox_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) { BCategoryList.Visibility = Visibility.Collapsed; e.Handled = true; } + } + + private void CategoryList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (BCategoryList.SelectedIndex < 0) return; + var suggestions = BCategoryList.Tag as List; + if (suggestions == null || BCategoryList.SelectedIndex >= suggestions.Count) return; + var cat = suggestions[BCategoryList.SelectedIndex]; + _suppressCategoryLookup = true; + _draft.CategoryId = cat.CategoryId; + _draft.CategoryName = cat.CategoryName; + BCategoryBox.Text = cat.CategoryName; + BCategoryIdLabel.Text = $"ID: {cat.CategoryId}"; + BCategoryList.Visibility = Visibility.Collapsed; + _suppressCategoryLookup = false; + } + + private async Task AutoFillCategoryAsync(string keyword) + { + if (_categoryService == null || string.IsNullOrWhiteSpace(keyword)) return; + try + { + var suggestions = await _categoryService.GetCategorySuggestionsAsync(keyword); + if (suggestions.Count == 0) return; + var top = suggestions[0]; + _suppressCategoryLookup = true; + _draft.CategoryId = top.CategoryId; + _draft.CategoryName = top.CategoryName; + BCategoryBox.Text = top.CategoryName; + BCategoryIdLabel.Text = $"ID: {top.CategoryId}"; + _suppressCategoryLookup = false; + BCategoryList.ItemsSource = suggestions.Select(s => s.CategoryName).ToList(); + BCategoryList.Tag = suggestions; + BCategoryList.Visibility = suggestions.Count > 1 ? Visibility.Visible : Visibility.Collapsed; + } + catch { } + } + + // ---- Condition ---- + + private void ConditionBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _draft.Condition = GetSelectedCondition(); + } + + private ItemCondition GetSelectedCondition() + { + var tag = (BConditionBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Used"; + return tag switch + { + "New" => ItemCondition.New, + "OpenBox" => ItemCondition.OpenBox, + "Refurbished" => ItemCondition.Refurbished, + "ForParts" => ItemCondition.ForPartsOrNotWorking, + _ => ItemCondition.Used + }; + } + + // ---- Price ---- + + private async void AiPrice_Click(object sender, RoutedEventArgs e) + { + if (_aiService == null) return; + SetPriceBusy(true); + try + { + var result = await _aiService.SuggestPriceAsync(BTitleBox.Text, GetSelectedCondition().ToString()); + var lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var priceLine = lines.FirstOrDefault(l => l.StartsWith("PRICE:", StringComparison.OrdinalIgnoreCase)); + _suggestedPriceValue = priceLine?.Replace("PRICE:", "", StringComparison.OrdinalIgnoreCase).Trim() ?? ""; + BPriceHint.Text = lines.FirstOrDefault() ?? result; + BPriceHint.Visibility = Visibility.Visible; + if (decimal.TryParse(_suggestedPriceValue, out var price)) + BPriceBox.Value = (double)price; + } + catch (Exception ex) { ShowError("AI Price", ex.Message); } + finally { SetPriceBusy(false); } + } + + private void PriceBox_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + => UpdateFeeEstimate(); + + private void PostageBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + => UpdateFeeEstimate(); + + private static readonly Dictionary PostageEstimates = new() + { + ["RoyalMailFirstClass"] = 3.70m, + ["RoyalMailSecondClass"] = 2.85m, + ["RoyalMailTracked24"] = 4.35m, + ["RoyalMailTracked48"] = 3.60m, + ["CollectionOnly"] = 0m, + ["FreePostage"] = 0m, + }; + + private void UpdateFeeEstimate() + { + if (BFeeLabel == null) return; + var price = (decimal)(BPriceBox?.Value ?? 0); + if (price <= 0) { BFeeLabel.Visibility = Visibility.Collapsed; return; } + + var postageTag = (BPostageBox?.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? ""; + PostageEstimates.TryGetValue(postageTag, out var postageEst); + + const decimal fvfRate = 0.128m; + const decimal minFee = 0.30m; + var fee = Math.Max(Math.Round((price + postageEst) * fvfRate, 2), minFee); + + var postageNote = postageEst > 0 ? $" + est. \u00A3{postageEst:F2} postage" : ""; + BFeeLabel.Text = $"Est. eBay fee: \u00A3{fee:F2} (12.8% of \u00A3{price:F2}{postageNote})"; + BFeeLabel.Visibility = Visibility.Visible; + } + private void SetPriceBusy(bool busy) + { + AiPriceBtn.IsEnabled = !busy; + PriceSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + PriceAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible; + } + + // ---- Photos (State B) ---- + + private void RebuildBPhotoThumbnails() + { + BPhotosPanel.Children.Clear(); + for (int i = 0; i < _draft.PhotoPaths.Count; i++) + AddBPhotoThumbnail(_draft.PhotoPaths[i], i); + BPhotoCount.Text = $"{_draft.PhotoPaths.Count} / {MaxPhotos}"; + BPhotoCount.Foreground = _draft.PhotoPaths.Count >= MaxPhotos + ? System.Windows.Media.Brushes.OrangeRed + : (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5"); + } + + private void AddBPhotoThumbnail(string path, int index) + { + try + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.UriSource = new Uri(path, UriKind.Absolute); + bmp.DecodePixelWidth = 320; + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.EndInit(); + bmp.Freeze(); + + var img = new Image + { + Width = 200, Height = 200, + Stretch = System.Windows.Media.Stretch.UniformToFill, + Source = bmp, ToolTip = System.IO.Path.GetFileName(path) + }; + img.Clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 200, 200), 6, 6); + + var removeBtn = new Button + { + Width = 18, Height = 18, Content = "\u2715", + FontSize = 11, FontWeight = FontWeights.Bold, + Cursor = Cursors.Hand, ToolTip = "Remove", + 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), Opacity = 0 + }; + removeBtn.Click += (s, ev) => + { + ev.Handled = true; + _draft.PhotoPaths.Remove(path); + RebuildBPhotoThumbnails(); + }; + + Border? coverBadge = null; + if (index == 0) + { + coverBadge = new Border + { + CornerRadius = new CornerRadius(3), + Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(210, 60, 90, 200)), + Padding = new Thickness(3, 1, 3, 1), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(2, 2, 0, 0), + IsHitTestVisible = false, + Child = new TextBlock + { + Text = "Cover", FontSize = 8, FontWeight = FontWeights.SemiBold, + Foreground = System.Windows.Media.Brushes.White + } + }; + } + + var container = new Grid + { + Width = 200, Height = 200, Margin = new Thickness(4), + Cursor = Cursors.SizeAll, AllowDrop = true, Tag = path + }; + container.Children.Add(img); + if (coverBadge != null) container.Children.Add(coverBadge); + container.Children.Add(removeBtn); + + container.MouseEnter += (s, ev) => removeBtn.Opacity = 1; + container.MouseLeave += (s, ev) => removeBtn.Opacity = 0; + + Point dragStart = default; + bool isDragging = false; + container.MouseLeftButtonDown += (s, ev) => dragStart = ev.GetPosition(null); + container.MouseMove += (s, ev) => + { + if (ev.LeftButton != MouseButtonState.Pressed || isDragging) return; + var pos = ev.GetPosition(null); + if (Math.Abs(pos.X - dragStart.X) > SystemParameters.MinimumHorizontalDragDistance || + Math.Abs(pos.Y - dragStart.Y) > SystemParameters.MinimumVerticalDragDistance) + { + isDragging = true; + DragDrop.DoDragDrop(container, path, DragDropEffects.Move); + isDragging = false; + } + }; + container.DragOver += (s, ev) => + { + if (ev.Data.GetDataPresent(typeof(string)) && + (string)ev.Data.GetData(typeof(string)) != path) + { ev.Effects = DragDropEffects.Move; container.Opacity = 0.45; } + else ev.Effects = DragDropEffects.None; + ev.Handled = true; + }; + container.DragLeave += (s, ev) => container.Opacity = 1.0; + container.Drop += (s, ev) => + { + container.Opacity = 1.0; + if (!ev.Data.GetDataPresent(typeof(string))) return; + var src = (string)ev.Data.GetData(typeof(string)); + var tgt = (string)container.Tag; + if (src == tgt) return; + var si = _draft.PhotoPaths.IndexOf(src); + var ti = _draft.PhotoPaths.IndexOf(tgt); + if (si < 0 || ti < 0) return; + _draft.PhotoPaths.RemoveAt(si); + _draft.PhotoPaths.Insert(ti, src); + RebuildBPhotoThumbnails(); + ev.Handled = true; + }; + + BPhotosPanel.Children.Add(container); + } + catch { } + } + + private void AddMorePhotos_Click(object sender, RoutedEventArgs e) + { + var dlg = new OpenFileDialog + { + Title = "Add more photos", + Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*", + Multiselect = true + }; + if (dlg.ShowDialog() == true) + { + foreach (var path in dlg.FileNames) + { + if (_draft.PhotoPaths.Count >= MaxPhotos) break; + if (!_draft.PhotoPaths.Contains(path)) _draft.PhotoPaths.Add(path); + } + RebuildBPhotoThumbnails(); + } + } + + // ---- Footer actions ---- + + private void StartOver_Click(object sender, RoutedEventArgs e) + { + var isDirty = !string.IsNullOrWhiteSpace(BTitleBox.Text) || + !string.IsNullOrWhiteSpace(BDescBox.Text); + if (isDirty) + { + var result = MessageBox.Show("Start over? Any edits will be lost.", + "Start Over", MessageBoxButton.OKCancel, MessageBoxImage.Question); + if (result != MessageBoxResult.OK) return; + } + ResetToStateA(); + } + + public void ResetToStateA() + { + _photoPaths.Clear(); + _draft = new ListingDraft { Postcode = _defaultPostcode }; + _lastAnalysis = null; + UpdateThumbStrip(); + UpdateAnalyseButton(); + if (BPhotosPanel != null) BPhotosPanel.Children.Clear(); + if (BTitleBox != null) BTitleBox.Text = ""; + if (BDescBox != null) BDescBox.Text = ""; + if (BCategoryBox != null) { BCategoryBox.Text = ""; BCategoryList.Visibility = Visibility.Collapsed; } + if (BCategoryIdLabel != null) BCategoryIdLabel.Text = "(no category selected)"; + if (BPriceBox != null) BPriceBox.Value = 0; + if (BPriceHint != null) BPriceHint.Visibility = Visibility.Collapsed; + if (BConditionBox != null) BConditionBox.SelectedIndex = 3; + if (BFormatBox != null) BFormatBox.SelectedIndex = 0; + if (BPostcodeBox != null) BPostcodeBox.Text = _defaultPostcode; + SetState(ListingState.DropZone); + } + + private void SaveDraft_Click(object sender, RoutedEventArgs e) + { + if (_savedService == null) return; + if (!ValidateDraft()) return; + CollectDraftFromFields(); + try + { + _savedService.Save( + _draft.Title, _draft.Description, _draft.Price, + _draft.CategoryName, "", + _draft.PhotoPaths, + _draft.CategoryId, _draft.Condition, _draft.Format, + BPostcodeBox.Text); + GetWindow()?.RefreshSavedListings(); + GetWindow()?.SetStatus($"Draft saved: {_draft.Title}"); + ResetToStateA(); + } + catch (Exception ex) { ShowError("Save Failed", ex.Message); } + } + + private async void Post_Click(object sender, RoutedEventArgs e) + { + if (_listingService == null) return; + if (!ValidateDraft()) return; + CollectDraftFromFields(); + SetPostBusy(true); + try + { + var url = await _listingService.PostListingAsync(_draft); + _draft.EbayListingUrl = url; + + // Persist a record of the posting + if (_savedService != null) + { + try + { + _savedService.Save( + _draft.Title, _draft.Description, _draft.Price, + _draft.CategoryName, $"Posted: {url}", + _draft.PhotoPaths, + _draft.CategoryId, _draft.Condition, _draft.Format, + _draft.Postcode); + GetWindow()?.RefreshSavedListings(); + } + catch { /* non-critical — posting succeeded, history save is best-effort */ } + } + + BSuccessUrl.Text = url; + SetState(ListingState.Success); + GetWindow()?.SetStatus($"Listed: {_draft.Title}"); + } + catch (Exception ex) + { + // Log full stack trace to help diagnose crashes + try + { + var logPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "EbayListingTool", "crash_log.txt"); + var msg = $"{DateTime.Now:HH:mm:ss} [Post_Click] {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n"; + if (ex.InnerException != null) + msg += $" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}\n"; + System.IO.File.AppendAllText(logPath, msg + "\n"); + } + catch { } + ShowError("Post Failed", ex.Message); + } + finally { SetPostBusy(false); } + } + + private void CollectDraftFromFields() + { + _draft.Title = BTitleBox.Text.Trim(); + _draft.Description = BDescBox.Text.Trim(); + _draft.Price = (decimal)(BPriceBox.Value ?? 0); + _draft.Condition = GetSelectedCondition(); + _draft.Format = BFormatBox.SelectedIndex == 0 ? ListingFormat.FixedPrice : ListingFormat.Auction; + _draft.Postcode = BPostcodeBox.Text; + _draft.Quantity = 1; + } + + private bool ValidateDraft() + { + if (string.IsNullOrWhiteSpace(BTitleBox?.Text)) + { ShowError("Validation", "Please enter a title."); return false; } + if (BTitleBox.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 ((BPriceBox?.Value ?? 0) <= 0) + { ShowError("Validation", "Please enter a price greater than zero."); return false; } + return true; + } + + private void SetPostBusy(bool busy) + { + PostBtn.IsEnabled = !busy; + PostSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + PostIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible; + IsEnabled = !busy; + } + + private void ShowError(string title, string msg) + => MessageBox.Show(msg, title, MessageBoxButton.OK, MessageBoxImage.Warning); + + // ---- State C handlers ---- + + private void SuccessUrl_Click(object sender, MouseButtonEventArgs e) + { + var url = BSuccessUrl.Text; + if (!string.IsNullOrEmpty(url)) + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) + { UseShellExecute = true }); + } + + private void CopyUrl_Click(object sender, RoutedEventArgs e) + => System.Windows.Clipboard.SetText(BSuccessUrl.Text); + + private void ListAnother_Click(object sender, RoutedEventArgs e) + => ResetToStateA(); + + private static bool IsImageFile(string path) + { + var ext = System.IO.Path.GetExtension(path).ToLowerInvariant(); + return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp"; + } + + private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow; +} diff --git a/EbayListingTool/Views/SavedListingsView.xaml b/EbayListingTool/Views/SavedListingsView.xaml index bdd50ca..08f1b9a 100644 --- a/EbayListingTool/Views/SavedListingsView.xaml +++ b/EbayListingTool/Views/SavedListingsView.xaml @@ -2,7 +2,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" - xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"> + xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" + KeyboardNavigation.TabNavigation="Cycle"> @@ -41,6 +42,22 @@ + + + + + + @@ -51,7 +68,7 @@ @@ -72,7 +89,8 @@ + Foreground="{DynamicResource MahApps.Brushes.Accent}" + IsTabStop="False"/> + ToolTip="Open exports folder in Explorer" + AutomationProperties.Name="Open exports folder in Explorer"> @@ -104,18 +123,20 @@ Width="13" Height="13" Margin="0,0,7,0" VerticalAlignment="Center" - Foreground="{DynamicResource MahApps.Brushes.Gray5}"/> + Foreground="{DynamicResource MahApps.Brushes.Gray5}" + IsTabStop="False"/> + TextChanged="SearchBox_TextChanged" + AutomationProperties.Name="Filter saved listings"/> + Padding="10,8" Focusable="False"> + + Foreground="{DynamicResource MahApps.Brushes.Gray5}" + IsTabStop="False"/> + Margin="0,0,0,12" + IsTabStop="False"/> + Background="{DynamicResource MahApps.Brushes.Gray8}" + AutomationProperties.Name="Resize listings panel"/> @@ -185,7 +210,8 @@ + Margin="0,0,0,14" + IsTabStop="False"/> @@ -193,7 +219,8 @@ + VerticalScrollBarVisibility="Auto" Padding="18,14" + Focusable="False"> @@ -219,7 +246,8 @@ @@ -270,20 +302,22 @@ - + + Foreground="{DynamicResource MahApps.Brushes.Gray5}" + IsTabStop="False"/> - + Foreground="{DynamicResource MahApps.Brushes.Gray5}" + IsTabStop="False"/> @@ -293,6 +327,7 @@ @@ -305,26 +340,6 @@ - - - - - - - - - - - + -