diff --git a/EbayListingTool/Views/NewListingView.xaml b/EbayListingTool/Views/NewListingView.xaml
index eb90c80..2610f01 100644
--- a/EbayListingTool/Views/NewListingView.xaml
+++ b/EbayListingTool/Views/NewListingView.xaml
@@ -106,8 +106,304 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/EbayListingTool/Views/NewListingView.xaml.cs b/EbayListingTool/Views/NewListingView.xaml.cs
index 8e9f211..59fceb4 100644
--- a/EbayListingTool/Views/NewListingView.xaml.cs
+++ b/EbayListingTool/Views/NewListingView.xaml.cs
@@ -23,7 +23,7 @@ public partial class NewListingView : UserControl
private readonly List _photoPaths = new();
private const int MaxPhotos = 12;
- // State B — draft being edited (stub, populated in Task 4)
+ // State B — draft being edited
private ListingDraft _draft = new();
private PhotoAnalysisResult? _lastAnalysis;
private bool _suppressCategoryLookup;
@@ -211,20 +211,493 @@ public partial class NewListingView : UserControl
}
}
- // Stub for State B — implemented in Task 4
- private Task PopulateStateBAsync(PhotoAnalysisResult result) => Task.CompletedTask;
+ // ---- 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: £{result.PriceMin:F2} – £{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;
+ Dispatcher.Invoke(() =>
+ {
+ 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 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 = 128;
+ bmp.CacheOption = BitmapCacheOption.OnLoad;
+ bmp.EndInit();
+ bmp.Freeze();
+
+ var img = new Image
+ {
+ Width = 72, Height = 72,
+ 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, 72, 72), 4, 4);
+
+ 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 = 72, Height = 72, 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();
+ }
- // Stub for ResetToStateA — implemented in Task 4
public void ResetToStateA()
{
_photoPaths.Clear();
- _draft = new ListingDraft { Postcode = _defaultPostcode };
+ _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 async 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, GetSelectedCondition().ToString(),
+ _draft.PhotoPaths);
+ GetWindow()?.RefreshSavedListings();
+ GetWindow()?.SetStatus($"Draft saved: {_draft.Title}");
+ SaveDraftBtn.IsEnabled = false;
+ await Task.Delay(600);
+ SaveDraftBtn.IsEnabled = true;
+ 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;
+ var urlBox = FindName("BSuccessUrl") as TextBlock;
+ if (urlBox != null) urlBox.Text = url;
+ SetState(ListingState.Success);
+ GetWindow()?.SetStatus($"Listed: {_draft.Title}");
+ }
+ catch (Exception ex) { 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);
+
private static bool IsImageFile(string path)
{
var ext = System.IO.Path.GetExtension(path).ToLowerInvariant();