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();