Add project files.
This commit is contained in:
22
EbayListingTool.sln
Normal file
22
EbayListingTool.sln
Normal file
@@ -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
|
||||
14
EbayListingTool/App.xaml
Normal file
14
EbayListingTool/App.xaml
Normal file
@@ -0,0 +1,14 @@
|
||||
<Application x:Class="EbayListingTool.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="Views/MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Controls.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Fonts.xaml" />
|
||||
<ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Themes/Light.Indigo.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
66
EbayListingTool/App.xaml.cs
Normal file
66
EbayListingTool/App.xaml.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
37
EbayListingTool/EbayListingTool.csproj
Normal file
37
EbayListingTool/EbayListingTool.csproj
Normal file
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<AssemblyName>EbayListingTool</AssemblyName>
|
||||
<RootNamespace>EbayListingTool</RootNamespace>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- UI Theme -->
|
||||
<PackageReference Include="MahApps.Metro" Version="2.4.10" />
|
||||
<PackageReference Include="MahApps.Metro.IconPacks" Version="4.11.0" />
|
||||
|
||||
<!-- Spreadsheet / CSV import -->
|
||||
<PackageReference Include="ClosedXML" Version="0.103.0" />
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
|
||||
<!-- Config + JSON -->
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="sample-import.csv">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
8
EbayListingTool/GlobalUsings.cs
Normal file
8
EbayListingTool/GlobalUsings.cs
Normal file
@@ -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;
|
||||
16
EbayListingTool/Models/AppSettings.cs
Normal file
16
EbayListingTool/Models/AppSettings.cs
Normal file
@@ -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; } = "";
|
||||
}
|
||||
134
EbayListingTool/Models/BulkImportRow.cs
Normal file
134
EbayListingTool/Models/BulkImportRow.cs
Normal file
@@ -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 => "❌",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// String key used by XAML DataTriggers to apply colour-coded status badges.
|
||||
/// Values: "Posted" | "Failed" | "Enhancing" | "Posting" | "Pending" | "Ready"
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
16
EbayListingTool/Models/EbayToken.cs
Normal file
16
EbayListingTool/Models/EbayToken.cs
Normal file
@@ -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;
|
||||
}
|
||||
164
EbayListingTool/Models/ListingDraft.cs
Normal file
164
EbayListingTool/Models/ListingDraft.cs
Normal file
@@ -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<string> _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<string> 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));
|
||||
}
|
||||
23
EbayListingTool/Models/PhotoAnalysisResult.cs
Normal file
23
EbayListingTool/Models/PhotoAnalysisResult.cs
Normal file
@@ -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}" : "";
|
||||
}
|
||||
22
EbayListingTool/Models/SavedListing.cs
Normal file
22
EbayListingTool/Models/SavedListing.cs
Normal file
@@ -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; } = "";
|
||||
|
||||
/// <summary>Absolute paths to photos inside ExportFolder.</summary>
|
||||
public List<string> 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");
|
||||
}
|
||||
315
EbayListingTool/Services/AiAssistantService.cs
Normal file
315
EbayListingTool/Services/AiAssistantService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Calls Claude via OpenRouter (https://openrouter.ai) using the OpenAI-compatible
|
||||
/// chat completions endpoint. Sign up at openrouter.ai to get an API key.
|
||||
/// </summary>
|
||||
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<string> 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<string> 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<string> SuggestPriceAsync(string title, string condition, IEnumerable<decimal>? 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<string> 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}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Convenience wrapper — analyses a single photo.</summary>
|
||||
public Task<PhotoAnalysisResult> AnalyseItemFromPhotoAsync(string imagePath)
|
||||
=> AnalyseItemFromPhotosAsync(new[] { imagePath });
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<PhotoAnalysisResult> AnalyseItemFromPhotosAsync(IEnumerable<string> 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<string>(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<decimal>() ?? 0,
|
||||
PriceMin = obj["price_min"]?.Value<decimal>() ?? 0,
|
||||
PriceMax = obj["price_max"]?.Value<decimal>() ?? 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<string> CallWithVisionAsync(IEnumerable<string> 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<string> 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.");
|
||||
}
|
||||
}
|
||||
100
EbayListingTool/Services/BulkImportService.cs
Normal file
100
EbayListingTool/Services/BulkImportService.cs
Normal file
@@ -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<BulkImportRow> 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<BulkImportRow> 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<BulkImportRow>();
|
||||
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<BulkImportRow> ImportExcel(string filePath)
|
||||
{
|
||||
var rows = new List<BulkImportRow>();
|
||||
|
||||
using var workbook = new XLWorkbook(filePath);
|
||||
var ws = workbook.Worksheets.First();
|
||||
|
||||
// Find header row (row 1)
|
||||
var headers = new Dictionary<string, int>(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;
|
||||
}
|
||||
221
EbayListingTool/Services/EbayAuthService.cs
Normal file
221
EbayListingTool/Services/EbayAuthService.cs
Normal file
@@ -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<EbaySettings>() ?? 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<string> 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<string> 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 = "<html><body><h2>Connected! You can close this tab.</h2></body></html>";
|
||||
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<string, string>
|
||||
{
|
||||
["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<int>()),
|
||||
RefreshTokenExpiry = DateTime.UtcNow.AddSeconds(obj["refresh_token_expires_in"]!.Value<int>()),
|
||||
};
|
||||
|
||||
// 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<string, string>
|
||||
{
|
||||
["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<int>());
|
||||
SaveToken(_token);
|
||||
}
|
||||
|
||||
private async Task<string> 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<EbayToken>(json);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private void SaveToken(EbayToken token)
|
||||
{
|
||||
File.WriteAllText(_tokenPath, JsonConvert.SerializeObject(token, Formatting.Indented));
|
||||
}
|
||||
}
|
||||
76
EbayListingTool/Services/EbayCategoryService.cs
Normal file
76
EbayListingTool/Services/EbayCategoryService.cs
Normal file
@@ -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<List<CategorySuggestion>> GetCategorySuggestionsAsync(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) || query.Length < 3)
|
||||
return new List<CategorySuggestion>();
|
||||
|
||||
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<CategorySuggestion>();
|
||||
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<CategorySuggestion>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetCategoryIdByKeywordAsync(string keyword)
|
||||
{
|
||||
var suggestions = await GetCategorySuggestionsAsync(keyword);
|
||||
return suggestions.FirstOrDefault()?.CategoryId;
|
||||
}
|
||||
}
|
||||
274
EbayListingTool/Services/EbayListingService.cs
Normal file
274
EbayListingTool/Services/EbayListingService.cs
Normal file
@@ -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<string> 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<string> imageUrls, string token)
|
||||
{
|
||||
using var http = BuildClient(token);
|
||||
|
||||
var aspects = new Dictionary<string, List<string>>();
|
||||
|
||||
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<string> 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<string> 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<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
|
||||
{
|
||||
var urls = new List<string>();
|
||||
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<string?> 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 = $"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<UploadSiteHostedPicturesRequest xmlns="urn:ebay:apis:eBLBaseComponents">
|
||||
<RequesterCredentials>
|
||||
<eBayAuthToken>{token}</eBayAuthToken>
|
||||
</RequesterCredentials>
|
||||
<PictureName>{Path.GetFileNameWithoutExtension(filePath)}</PictureName>
|
||||
<PictureSet>Supersize</PictureSet>
|
||||
<ExternalPictureURL>https://example.com/placeholder.jpg</ExternalPictureURL>
|
||||
</UploadSiteHostedPicturesRequest>
|
||||
""";
|
||||
|
||||
// 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, @"<FullURL>(.*?)</FullURL>");
|
||||
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;
|
||||
}
|
||||
}
|
||||
167
EbayListingTool/Services/SavedListingsService.cs
Normal file
167
EbayListingTool/Services/SavedListingsService.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using EbayListingTool.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace EbayListingTool.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists saved listings to %APPDATA%\EbayListingTool\saved_listings.json
|
||||
/// and exports each listing to its own subfolder under
|
||||
/// %APPDATA%\EbayListingTool\Exports\{ItemName}\
|
||||
/// </summary>
|
||||
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<SavedListing> _listings = new();
|
||||
|
||||
public IReadOnlyList<SavedListing> Listings => _listings;
|
||||
|
||||
public SavedListingsService()
|
||||
{
|
||||
Directory.CreateDirectory(AppDataDir);
|
||||
Directory.CreateDirectory(ExportsDir);
|
||||
Load();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the listing: copies photos to an export folder, writes a text file,
|
||||
/// appends to the JSON index, and returns (listing, skippedPhotoCount).
|
||||
/// </summary>
|
||||
public (SavedListing Listing, int SkippedPhotos) Save(
|
||||
string title, string description, decimal price,
|
||||
string category, string conditionNotes,
|
||||
IEnumerable<string> 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<string>();
|
||||
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<List<SavedListing>>(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})";
|
||||
}
|
||||
}
|
||||
484
EbayListingTool/Views/BulkImportView.xaml
Normal file
484
EbayListingTool/Views/BulkImportView.xaml
Normal file
@@ -0,0 +1,484 @@
|
||||
<UserControl x:Class="EbayListingTool.Views.BulkImportView"
|
||||
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">
|
||||
|
||||
<UserControl.Resources>
|
||||
|
||||
<!-- Shared value converters via inline DataTriggers -->
|
||||
|
||||
<!-- Status text colour: applied to DataGrid cells via a DataTemplate -->
|
||||
<Style x:Key="StatusTextStyle" TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||
</Style>
|
||||
|
||||
<!-- DataGrid row style: alternating shading + status-driven foreground -->
|
||||
<Style x:Key="BulkRowStyle" TargetType="DataGridRow"
|
||||
BasedOn="{StaticResource MahApps.Styles.DataGridRow}">
|
||||
<!-- Even rows get a very slight alternate tint -->
|
||||
<Style.Triggers>
|
||||
<Trigger Property="AlternationIndex" Value="1">
|
||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Highlight}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- AI toolbar button: indigo gradient matching SingleItemView -->
|
||||
<Style x:Key="AiToolbarButton" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||
<Setter Property="Height" Value="32"/>
|
||||
<Setter Property="Padding" Value="12,0"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#8B5CF6" Offset="0"/>
|
||||
<GradientStop Color="#6366F1" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Small AI row button -->
|
||||
<Style x:Key="AiRowButton" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||
<Setter Property="Width" Value="28"/>
|
||||
<Setter Property="Height" Value="24"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="Margin" Value="2,0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#8B5CF6" Offset="0"/>
|
||||
<GradientStop Color="#6366F1" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Margin="16,12,16,12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- ================================================================
|
||||
TOOLBAR
|
||||
================================================================ -->
|
||||
<Border Grid.Row="0"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}"
|
||||
BorderThickness="1" CornerRadius="4"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray10}"
|
||||
Padding="10,8" Margin="0,0,0,10">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Open file -->
|
||||
<Button Grid.Column="0" Click="OpenFile_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
||||
Height="32" Padding="12,0" FontSize="13">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Open CSV / Excel" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Download template -->
|
||||
<Button Grid.Column="1" Click="DownloadTemplate_Click" Margin="8,0,0,0"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="32" Padding="12,0" FontSize="13">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Download" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Template" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- AI Enhance All — gradient AI button with spinner -->
|
||||
<Button Grid.Column="2" x:Name="EnhanceAllBtn" Margin="8,0,0,0"
|
||||
Click="AiEnhanceAll_Click"
|
||||
Style="{StaticResource AiToolbarButton}"
|
||||
IsEnabled="False">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<mah:ProgressRing x:Name="EnhanceSpinner"
|
||||
Width="14" Height="14" Margin="0,0,6,0"
|
||||
Foreground="White" Visibility="Collapsed"/>
|
||||
<iconPacks:PackIconMaterial x:Name="EnhanceIcon"
|
||||
Kind="AutoFix" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="AI Enhance All" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Post All -->
|
||||
<Button Grid.Column="3" x:Name="PostAllBtn" Margin="8,0,0,0"
|
||||
Click="PostAll_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
||||
Height="32" Padding="12,0" FontSize="13" FontWeight="SemiBold"
|
||||
IsEnabled="False">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<mah:ProgressRing x:Name="PostAllSpinner"
|
||||
Width="14" Height="14" Margin="0,0,6,0"
|
||||
Foreground="White" Visibility="Collapsed"/>
|
||||
<iconPacks:PackIconMaterial x:Name="PostAllIcon"
|
||||
Kind="Send" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Post All" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Clear -->
|
||||
<Button Grid.Column="4" x:Name="ClearBtn" Margin="8,0,0,0"
|
||||
Click="ClearAll_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="32" Padding="12,0" FontSize="13"
|
||||
IsEnabled="False">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Clear" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- Row count — right-aligned -->
|
||||
<Border Grid.Column="6" CornerRadius="10" Padding="10,3"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="TableRowsPlusAfter" Width="12" Height="12"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"/>
|
||||
<TextBlock x:Name="RowCountLabel" VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"
|
||||
Text="No file loaded"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ================================================================
|
||||
PROGRESS BAR
|
||||
================================================================ -->
|
||||
<Grid Grid.Row="1">
|
||||
<mah:MetroProgressBar x:Name="ProgressBar" Height="5"
|
||||
Margin="0,0,0,10" Visibility="Collapsed"
|
||||
Minimum="0" Maximum="100"/>
|
||||
</Grid>
|
||||
|
||||
<!-- ================================================================
|
||||
SUMMARY BAR (shown after posting)
|
||||
================================================================ -->
|
||||
<Border Grid.Row="2" x:Name="SummaryBar" Visibility="Collapsed"
|
||||
CornerRadius="4" Padding="12,6" Margin="0,0,0,8"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray10}"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}" BorderThickness="1">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Summary text set from code-behind via SummaryLabel.Text -->
|
||||
<TextBlock x:Name="SummaryLabel" Grid.Column="0"
|
||||
FontSize="12" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||
<!-- Colour-coded stat pills -->
|
||||
<Border Grid.Column="1" CornerRadius="10" Padding="8,2" Margin="8,0,0,0"
|
||||
Background="#1A4CAF50" BorderBrush="#4CAF50" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="6" Height="6" Fill="#4CAF50" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="SummaryPostedPill" Text="0 posted" FontSize="11"
|
||||
Foreground="#4CAF50" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2" CornerRadius="10" Padding="8,2" Margin="6,0,0,0"
|
||||
Background="#1AE53935" BorderBrush="#E53935" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="6" Height="6" Fill="#E53935" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="SummaryFailedPill" Text="0 failed" FontSize="11"
|
||||
Foreground="#E53935" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="3" CornerRadius="10" Padding="8,2" Margin="6,0,0,0"
|
||||
Background="#1AFFA726" BorderBrush="#FFA726" BorderThickness="1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="6" Height="6" Fill="#FFA726" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="SummaryReadyPill" Text="0 ready" FontSize="11"
|
||||
Foreground="#FFA726" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ================================================================
|
||||
DATA GRID
|
||||
================================================================ -->
|
||||
<DataGrid Grid.Row="3" x:Name="ItemsGrid"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
SelectionMode="Single"
|
||||
HeadersVisibility="Column"
|
||||
GridLinesVisibility="Horizontal"
|
||||
AlternationCount="2"
|
||||
RowStyle="{StaticResource BulkRowStyle}"
|
||||
RowHeight="36"
|
||||
FontSize="12"
|
||||
Visibility="Collapsed"
|
||||
Style="{DynamicResource MahApps.Styles.DataGrid}">
|
||||
|
||||
<DataGrid.ColumnHeaderStyle>
|
||||
<Style TargetType="DataGridColumnHeader"
|
||||
BasedOn="{StaticResource MahApps.Styles.DataGridColumnHeader}">
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||
</Style>
|
||||
</DataGrid.ColumnHeaderStyle>
|
||||
|
||||
<DataGrid.Columns>
|
||||
|
||||
<!-- Status icon column -->
|
||||
<DataGridTextColumn Header="" Width="30"
|
||||
Binding="{Binding StatusIcon}"
|
||||
IsReadOnly="True"/>
|
||||
|
||||
<!-- Title — flexible fill -->
|
||||
<DataGridTextColumn Header="TITLE" Width="*"
|
||||
Binding="{Binding Title}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
|
||||
<!-- Condition -->
|
||||
<DataGridTextColumn Header="CONDITION" Width="90"
|
||||
Binding="{Binding Condition}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
|
||||
<!-- Category -->
|
||||
<DataGridTextColumn Header="CATEGORY" Width="140"
|
||||
Binding="{Binding CategoryKeyword}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
|
||||
<!-- Price -->
|
||||
<DataGridTextColumn Header="PRICE £" Width="70"
|
||||
Binding="{Binding Price}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
|
||||
<!-- Qty -->
|
||||
<DataGridTextColumn Header="QTY" Width="45"
|
||||
Binding="{Binding Quantity}">
|
||||
<DataGridTextColumn.ElementStyle>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
</DataGridTextColumn.ElementStyle>
|
||||
</DataGridTextColumn>
|
||||
|
||||
<!-- Status — colour-coded badge -->
|
||||
<DataGridTemplateColumn Header="STATUS" Width="140" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<!-- Badge whose colour is driven by Status enum via DataTriggers -->
|
||||
<Border CornerRadius="3" Padding="6,2" Margin="2,4"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<!-- Default: neutral grey -->
|
||||
<Setter Property="Background" Value="#22888888"/>
|
||||
<Setter Property="BorderBrush" Value="#55888888"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Style.Triggers>
|
||||
<!-- Posted → green -->
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Posted">
|
||||
<Setter Property="Background" Value="#1A4CAF50"/>
|
||||
<Setter Property="BorderBrush" Value="#4CAF50"/>
|
||||
</DataTrigger>
|
||||
<!-- Failed → red -->
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Failed">
|
||||
<Setter Property="Background" Value="#1AE53935"/>
|
||||
<Setter Property="BorderBrush" Value="#E53935"/>
|
||||
</DataTrigger>
|
||||
<!-- Enhancing → amber -->
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Enhancing">
|
||||
<Setter Property="Background" Value="#1AFFA726"/>
|
||||
<Setter Property="BorderBrush" Value="#FFA726"/>
|
||||
</DataTrigger>
|
||||
<!-- Posting → blue -->
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Posting">
|
||||
<Setter Property="Background" Value="#1A2196F3"/>
|
||||
<Setter Property="BorderBrush" Value="#2196F3"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding StatusMessage}"
|
||||
FontSize="11" FontWeight="SemiBold">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Posted">
|
||||
<Setter Property="Foreground" Value="#4CAF50"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Failed">
|
||||
<Setter Property="Foreground" Value="#E53935"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Enhancing">
|
||||
<Setter Property="Foreground" Value="#FFA726"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding StatusBadge}" Value="Posting">
|
||||
<Setter Property="Foreground" Value="#2196F3"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<!-- Actions: AI enhance + post row -->
|
||||
<DataGridTemplateColumn Header="ACTIONS" Width="80" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<!-- AI Enhance row -->
|
||||
<Button Style="{StaticResource AiRowButton}"
|
||||
ToolTip="AI Enhance this row"
|
||||
Click="AiEnhanceRow_Click" Tag="{Binding}">
|
||||
<iconPacks:PackIconMaterial Kind="AutoFix"
|
||||
Width="11" Height="11"/>
|
||||
</Button>
|
||||
<!-- Post row -->
|
||||
<Button Width="28" Height="24" Margin="2,0"
|
||||
ToolTip="Post this item"
|
||||
Click="PostRow_Click" Tag="{Binding}"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}">
|
||||
<iconPacks:PackIconMaterial Kind="Send"
|
||||
Width="11" Height="11"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<!-- ================================================================
|
||||
EMPTY STATE (shown before any file is loaded)
|
||||
================================================================ -->
|
||||
<Border Grid.Row="3" x:Name="EmptyState"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}"
|
||||
BorderThickness="1" CornerRadius="4"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray10}">
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<iconPacks:PackIconMaterial Kind="FileTableOutline"
|
||||
Width="56" Height="56"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,12"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
||||
<TextBlock Text="Open a CSV or Excel file to bulk import listings"
|
||||
FontSize="14" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"/>
|
||||
<TextBlock Text="Columns: Title, Description, Price, Condition, CategoryKeyword, Quantity, PhotoPaths"
|
||||
FontSize="11" HorizontalAlignment="Center" Margin="0,6,0,20"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||
TextWrapping="Wrap" MaxWidth="480" TextAlignment="Center"/>
|
||||
<Button HorizontalAlignment="Center"
|
||||
Click="DownloadTemplate_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
||||
Height="34" Padding="16,0" FontSize="13">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Download" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Download CSV Template" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
268
EbayListingTool/Views/BulkImportView.xaml.cs
Normal file
268
EbayListingTool/Views/BulkImportView.xaml.cs
Normal file
@@ -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<BulkImportRow> _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<BulkImportRow>(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;
|
||||
}
|
||||
}
|
||||
299
EbayListingTool/Views/MainWindow.xaml
Normal file
299
EbayListingTool/Views/MainWindow.xaml
Normal file
@@ -0,0 +1,299 @@
|
||||
<mah:MetroWindow x:Class="EbayListingTool.Views.MainWindow"
|
||||
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:local="clr-namespace:EbayListingTool.Views"
|
||||
Title="eBay Listing Tool — UK"
|
||||
Height="820" Width="1180"
|
||||
MinHeight="600" MinWidth="900"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
GlowBrush="{DynamicResource MahApps.Brushes.Accent}">
|
||||
|
||||
<mah:MetroWindow.Resources>
|
||||
<Style x:Key="ConnectedDotStyle" TargetType="Ellipse">
|
||||
<Setter Property="Width" Value="10"/>
|
||||
<Setter Property="Height" Value="10"/>
|
||||
<Setter Property="Margin" Value="0,0,6,0"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- Tab header style: larger font, more padding, accent bottom border on selected -->
|
||||
<Style x:Key="AppTabItem" TargetType="TabItem"
|
||||
BasedOn="{StaticResource MahApps.Styles.TabItem}">
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Padding" Value="16,10"/>
|
||||
<Setter Property="BorderThickness" Value="0,0,0,3"/>
|
||||
<Setter Property="BorderBrush" Value="Transparent"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Pulsing glow animation for the Connect button on lock overlays -->
|
||||
<Style x:Key="LockConnectButton" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square.Accent}">
|
||||
<Setter Property="Padding" Value="20,10"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect Color="#7C3AED" BlurRadius="12" ShadowDepth="0" Opacity="0.7"/>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<EventTrigger RoutedEvent="Button.Loaded">
|
||||
<BeginStoryboard>
|
||||
<Storyboard RepeatBehavior="Forever" AutoReverse="True">
|
||||
<DoubleAnimation
|
||||
Storyboard.TargetProperty="(UIElement.Effect).(DropShadowEffect.BlurRadius)"
|
||||
From="8" To="22" Duration="0:0:1.2"/>
|
||||
<DoubleAnimation
|
||||
Storyboard.TargetProperty="(UIElement.Effect).(DropShadowEffect.Opacity)"
|
||||
From="0.4" To="0.9" Duration="0:0:1.2"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</EventTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</mah:MetroWindow.Resources>
|
||||
|
||||
<mah:MetroWindow.RightWindowCommands>
|
||||
<mah:WindowCommands>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<Border CornerRadius="10" Padding="8,3" Margin="0,0,8,0"
|
||||
Background="#22FFFFFF" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse x:Name="StatusDot" Style="{StaticResource ConnectedDotStyle}" Fill="#777"/>
|
||||
<TextBlock x:Name="StatusLabel" Text="eBay: not connected"
|
||||
Foreground="White" VerticalAlignment="Center"
|
||||
FontSize="11" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button x:Name="ConnectBtn" Click="ConnectBtn_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
||||
Height="28" Padding="10,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Link" Width="12" Height="12"
|
||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Connect to eBay" VerticalAlignment="Center" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="DisconnectBtn" Visibility="Collapsed"
|
||||
Margin="6,0,0,0" Click="DisconnectBtn_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="28" Padding="8,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="LinkOff" Width="12" Height="12"
|
||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Disconnect" VerticalAlignment="Center" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</mah:WindowCommands>
|
||||
</mah:MetroWindow.RightWindowCommands>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TabControl x:Name="MainTabs" Grid.Row="0"
|
||||
Style="{DynamicResource MahApps.Styles.TabControl.Animated}">
|
||||
|
||||
<!-- ① Photo Analysis — always available, no eBay login needed -->
|
||||
<TabItem Style="{StaticResource AppTabItem}">
|
||||
<TabItem.Header>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Camera" Width="15" Height="15"
|
||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Photo Analyser" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<!-- Tab content: welcome banner + actual view stacked -->
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Welcome banner — only shown when no photo loaded yet (PhotoView sets Visibility via x:Name) -->
|
||||
<Border x:Name="WelcomeBanner" Grid.Row="0"
|
||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
||||
Padding="14,7" Visibility="Visible">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<iconPacks:PackIconMaterial Kind="Camera" Width="14" Height="14"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||
Foreground="White"/>
|
||||
<TextBlock Text="Drop a photo to identify any item and get an instant eBay price"
|
||||
Foreground="White" FontSize="12" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<local:PhotoAnalysisView x:Name="PhotoView" Grid.Row="1"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- ② New Listing — requires eBay connection -->
|
||||
<TabItem x:Name="NewListingTab" Style="{StaticResource AppTabItem}">
|
||||
<TabItem.Header>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="TagPlusOutline" Width="15" Height="15"
|
||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="New Listing" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<Grid>
|
||||
<local:SingleItemView x:Name="SingleView"/>
|
||||
<!-- Overlay shown when not connected -->
|
||||
<Border x:Name="NewListingOverlay" Visibility="Visible">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#F0F4FF" Offset="0"/>
|
||||
<GradientStop Color="#EEF2FF" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
MaxWidth="340">
|
||||
<!-- eBay logo circle -->
|
||||
<Border Width="72" Height="72" CornerRadius="36"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,18">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<iconPacks:PackIconMaterial Kind="CartOutline" Width="32" Height="32"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="Connect to eBay"
|
||||
FontSize="20" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"
|
||||
Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Sign in with your eBay account to start posting listings and managing your inventory."
|
||||
FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
Margin="0,0,0,24"/>
|
||||
<Button Click="ConnectBtn_Click"
|
||||
Style="{StaticResource LockConnectButton}"
|
||||
HorizontalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14"
|
||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Connect to eBay" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- ③ Saved Listings — always available -->
|
||||
<TabItem x:Name="SavedTab" Style="{StaticResource AppTabItem}">
|
||||
<TabItem.Header>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="15" Height="15"
|
||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Saved Listings" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<local:SavedListingsView x:Name="SavedView"/>
|
||||
</TabItem>
|
||||
|
||||
<!-- ④ Bulk Import — requires eBay connection -->
|
||||
<TabItem x:Name="BulkTab" Style="{StaticResource AppTabItem}">
|
||||
<TabItem.Header>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="TableMultiple" Width="15" Height="15"
|
||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Bulk Import" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</TabItem.Header>
|
||||
<Grid>
|
||||
<local:BulkImportView x:Name="BulkView"/>
|
||||
<Border x:Name="BulkOverlay" Visibility="Visible">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#F0F4FF" Offset="0"/>
|
||||
<GradientStop Color="#EEF2FF" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
MaxWidth="340">
|
||||
<!-- eBay logo circle -->
|
||||
<Border Width="72" Height="72" CornerRadius="36"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,18">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<iconPacks:PackIconMaterial Kind="TableArrowUp" Width="32" Height="32"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="Connect to eBay"
|
||||
FontSize="20" FontWeight="Bold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"
|
||||
Margin="0,0,0,8"/>
|
||||
<TextBlock Text="Sign in with your eBay account to bulk import and post multiple listings at once."
|
||||
FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
Margin="0,0,0,24"/>
|
||||
<Button Click="ConnectBtn_Click"
|
||||
Style="{StaticResource LockConnectButton}"
|
||||
HorizontalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14"
|
||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Connect to eBay" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
<!-- Status bar -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
||||
BorderThickness="0,1,0,0"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}">
|
||||
<Grid Margin="10,3">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="InformationOutline"
|
||||
Width="12" Height="12" Margin="0,0,5,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
<TextBlock x:Name="StatusBar" Text="Ready" FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Ellipse x:Name="StatusBarDot" Width="8" Height="8"
|
||||
Fill="#888" Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="StatusBarEbay" Text="eBay: disconnected"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</mah:MetroWindow>
|
||||
123
EbayListingTool/Views/MainWindow.xaml.cs
Normal file
123
EbayListingTool/Views/MainWindow.xaml.cs
Normal file
@@ -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<string> 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;
|
||||
}
|
||||
629
EbayListingTool/Views/PhotoAnalysisView.xaml
Normal file
629
EbayListingTool/Views/PhotoAnalysisView.xaml
Normal file
@@ -0,0 +1,629 @@
|
||||
<UserControl x:Class="EbayListingTool.Views.PhotoAnalysisView"
|
||||
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">
|
||||
|
||||
<UserControl.Resources>
|
||||
|
||||
<!-- ================================================================
|
||||
Styles
|
||||
================================================================ -->
|
||||
<Style x:Key="SectionCard" TargetType="Border">
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Padding" Value="14,12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
|
||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
||||
</Style>
|
||||
<Style x:Key="SectionHeading" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray2}"/>
|
||||
</Style>
|
||||
<Style x:Key="ResultValue" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray1}"/>
|
||||
</Style>
|
||||
<Style x:Key="AiButton" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#8B5CF6" Offset="0"/>
|
||||
<GradientStop Color="#6366F1" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Small icon-only clipboard button -->
|
||||
<Style x:Key="CopyButton" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||
<Setter Property="Width" Value="28"/>
|
||||
<Setter Property="Height" Value="28"/>
|
||||
<Setter Property="Padding" Value="4"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="ToolTip" Value="Copy to clipboard"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- ================================================================
|
||||
Drop zone dashed border animation
|
||||
================================================================ -->
|
||||
<Style x:Key="DashedDropBorder" TargetType="Border">
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
||||
<Setter Property="AllowDrop" Value="True"/>
|
||||
<Setter Property="MinHeight" Value="320"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
</Style>
|
||||
|
||||
<!-- ================================================================
|
||||
Results reveal animation
|
||||
================================================================ -->
|
||||
<Storyboard x:Key="ResultsReveal">
|
||||
<DoubleAnimation Storyboard.TargetName="ResultsPanel"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
From="0" To="1" Duration="0:0:0.25"/>
|
||||
<DoubleAnimation Storyboard.TargetName="ResultsTranslate"
|
||||
Storyboard.TargetProperty="Y"
|
||||
From="20" To="0" Duration="0:0:0.25">
|
||||
<DoubleAnimation.EasingFunction>
|
||||
<CubicEase EasingMode="EaseOut"/>
|
||||
</DoubleAnimation.EasingFunction>
|
||||
</DoubleAnimation>
|
||||
</Storyboard>
|
||||
|
||||
<!-- Camera icon pulse animation — both axes target the same ScaleTransform -->
|
||||
<Storyboard x:Key="CameraPulse" RepeatBehavior="Forever">
|
||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="CameraScale"
|
||||
Storyboard.TargetProperty="ScaleX">
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0.0" Value="1.0"/>
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:1.0" Value="1.08">
|
||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:2.0" Value="1.0">
|
||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
</DoubleAnimationUsingKeyFrames>
|
||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="CameraScale"
|
||||
Storyboard.TargetProperty="ScaleY">
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0.0" Value="1.0"/>
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:1.0" Value="1.08">
|
||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:2.0" Value="1.0">
|
||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
</DoubleAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
|
||||
</UserControl.Resources>
|
||||
|
||||
<!-- Start animations when control loads -->
|
||||
<UserControl.Triggers>
|
||||
<EventTrigger RoutedEvent="Loaded">
|
||||
<BeginStoryboard Storyboard="{StaticResource CameraPulse}"/>
|
||||
</EventTrigger>
|
||||
</UserControl.Triggers>
|
||||
|
||||
<Grid Margin="16,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="340"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ================================================================
|
||||
LEFT: Photo drop zone + analyse button
|
||||
================================================================ -->
|
||||
<Grid Grid.Column="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Drop zone with dashed border drawn via Rectangle overlay -->
|
||||
<Grid Grid.Row="0">
|
||||
<!-- Dashed border rectangle -->
|
||||
<Rectangle x:Name="DropBorderRect"
|
||||
RadiusX="10" RadiusY="10"
|
||||
StrokeDashArray="6,4"
|
||||
StrokeThickness="2"
|
||||
Stroke="{DynamicResource MahApps.Brushes.Gray6}"
|
||||
Fill="Transparent"
|
||||
IsHitTestVisible="False"/>
|
||||
|
||||
<Border x:Name="DropZone"
|
||||
CornerRadius="10"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray10}"
|
||||
AllowDrop="True"
|
||||
Drop="DropZone_Drop"
|
||||
DragOver="DropZone_DragOver"
|
||||
DragEnter="DropZone_DragEnter"
|
||||
DragLeave="DropZone_DragLeave"
|
||||
MinHeight="320"
|
||||
Cursor="Hand"
|
||||
MouseLeftButtonUp="DropZone_Click">
|
||||
|
||||
<!-- Wrapper grid so Border has only one child; children overlap via shared cell -->
|
||||
<Grid>
|
||||
<!-- Empty state hint -->
|
||||
<Grid x:Name="DropHint" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<StackPanel HorizontalAlignment="Center">
|
||||
<iconPacks:PackIconMaterial Kind="CameraPlus"
|
||||
x:Name="CameraIcon"
|
||||
Width="64" Height="64"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||
Margin="0,0,0,16"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<iconPacks:PackIconMaterial.RenderTransform>
|
||||
<ScaleTransform x:Name="CameraScale" ScaleX="1" ScaleY="1"/>
|
||||
</iconPacks:PackIconMaterial.RenderTransform>
|
||||
</iconPacks:PackIconMaterial>
|
||||
<TextBlock Text="Drop a photo here" FontSize="16" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||
<TextBlock Text="or click to browse" FontSize="12" Margin="0,4,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
<TextBlock Text="JPG · PNG · GIF · WEBP" FontSize="11" Margin="0,14,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Loaded photo with rounded clip and drop shadow -->
|
||||
<Grid x:Name="PhotoPreviewContainer" Visibility="Collapsed">
|
||||
<Grid.Effect>
|
||||
<DropShadowEffect BlurRadius="12" ShadowDepth="3" Opacity="0.25" Color="Black"/>
|
||||
</Grid.Effect>
|
||||
<Image x:Name="PhotoPreview"
|
||||
Stretch="Uniform"
|
||||
Margin="4"
|
||||
RenderOptions.BitmapScalingMode="HighQuality">
|
||||
<Image.Clip>
|
||||
<RectangleGeometry x:Name="PhotoClip" RadiusX="8" RadiusY="8"/>
|
||||
</Image.Clip>
|
||||
</Image>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Clear photo button (top-right overlay) -->
|
||||
<Button x:Name="ClearPhotoBtn"
|
||||
Visibility="Collapsed"
|
||||
Click="ClearPhoto_Click"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,8,8,0"
|
||||
Width="24" Height="24"
|
||||
Padding="3"
|
||||
ToolTip="Remove photo"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Background="#CC222222"
|
||||
BorderThickness="0">
|
||||
<iconPacks:PackIconMaterial Kind="Close" Width="12" Height="12" Foreground="White"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Photo filename label -->
|
||||
<TextBlock x:Name="PhotoFilename" Grid.Row="1"
|
||||
Text="" FontSize="11" Margin="0,6,0,0"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
|
||||
<!-- Thumbnail strip (hidden until 2+ photos loaded) -->
|
||||
<ScrollViewer Grid.Row="2"
|
||||
x:Name="ThumbStripScroller"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
Visibility="Collapsed"
|
||||
Margin="0,8,0,0">
|
||||
<WrapPanel x:Name="PhotoThumbStrip"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"/>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Analyse button -->
|
||||
<Button Grid.Row="3" x:Name="AnalyseBtn"
|
||||
Click="Analyse_Click"
|
||||
Style="{StaticResource AiButton}"
|
||||
Height="42" FontSize="15" Margin="0,10,0,0"
|
||||
IsEnabled="False">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<mah:ProgressRing x:Name="AnalyseSpinner"
|
||||
Width="18" Height="18" Margin="0,0,8,0"
|
||||
Foreground="White" Visibility="Collapsed"/>
|
||||
<iconPacks:PackIconMaterial x:Name="AnalyseIcon"
|
||||
Kind="Magnify" Width="18" Height="18"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||
<TextBlock x:Name="AnalyseBtnText"
|
||||
Text="Identify & Price with AI"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- ================================================================
|
||||
RIGHT: Results panel
|
||||
================================================================ -->
|
||||
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
|
||||
<!-- Idle state -->
|
||||
<Border x:Name="IdlePanel" Style="{StaticResource SectionCard}"
|
||||
MinHeight="400">
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<iconPacks:PackIconMaterial Kind="TagOutline"
|
||||
Width="52" Height="52"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
||||
Margin="0,0,0,16"/>
|
||||
<TextBlock Text="Drop a photo and click Identify"
|
||||
FontSize="15" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
<TextBlock Text="Claude will identify the item, write a listing description and suggest a realistic eBay UK selling price."
|
||||
FontSize="12" Margin="0,8,0,0"
|
||||
HorizontalAlignment="Center" TextAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
||||
TextWrapping="Wrap" MaxWidth="320"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Loading state (shown during analysis) -->
|
||||
<Border x:Name="LoadingPanel" Style="{StaticResource SectionCard}"
|
||||
MinHeight="400" Visibility="Collapsed">
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||
<mah:ProgressRing Width="48" Height="48"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,20"/>
|
||||
<TextBlock x:Name="LoadingStepText"
|
||||
Text="Examining the photo…"
|
||||
FontSize="15" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||
<TextBlock Text="This usually takes 10–20 seconds"
|
||||
FontSize="11" Margin="0,8,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Results (hidden until analysis complete) -->
|
||||
<StackPanel x:Name="ResultsPanel" Visibility="Collapsed" Opacity="0">
|
||||
<StackPanel.RenderTransform>
|
||||
<TranslateTransform x:Name="ResultsTranslate" Y="20"/>
|
||||
</StackPanel.RenderTransform>
|
||||
|
||||
<!-- Identified item -->
|
||||
<Border Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<iconPacks:PackIconMaterial Kind="CartOutline" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="ITEM IDENTIFIED" Style="{StaticResource SectionHeading}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Item name — large bold -->
|
||||
<TextBlock x:Name="ItemNameText"
|
||||
FontSize="20" FontWeight="Bold"
|
||||
TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
|
||||
|
||||
<!-- Brand/model pill badge -->
|
||||
<Border x:Name="BrandPill"
|
||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
||||
CornerRadius="12"
|
||||
Padding="10,3"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="0,0,0,10"
|
||||
Visibility="Collapsed">
|
||||
<TextBlock x:Name="BrandModelText"
|
||||
FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="White"/>
|
||||
</Border>
|
||||
|
||||
<!-- Condition notes — green tinted box with eye icon -->
|
||||
<Border Background="#1A4CAF50" BorderBrush="#4CAF50"
|
||||
BorderThickness="1" CornerRadius="5" Padding="10,8">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Eye" Width="13" Height="13"
|
||||
Margin="0,0,8,0" VerticalAlignment="Top"
|
||||
Foreground="#4CAF50"/>
|
||||
<TextBlock x:Name="ConditionText"
|
||||
FontSize="12" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"
|
||||
MaxWidth="340"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Confidence badge (High / Medium / Low) -->
|
||||
<Border x:Name="ConfidenceBadge"
|
||||
CornerRadius="10" Padding="8,3"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="0,10,0,0"
|
||||
Visibility="Collapsed">
|
||||
<TextBlock x:Name="ConfidenceText"
|
||||
FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="White"/>
|
||||
</Border>
|
||||
|
||||
<!-- Confidence notes -->
|
||||
<TextBlock x:Name="ConfidenceNotesText"
|
||||
FontSize="11" FontStyle="Italic"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,6,0,0"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
Visibility="Collapsed"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Price -->
|
||||
<Border Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="SUGGESTED PRICE" Style="{StaticResource SectionHeading}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Prominent price badge -->
|
||||
<Border HorizontalAlignment="Left"
|
||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
||||
CornerRadius="10"
|
||||
Padding="18,8"
|
||||
Margin="0,0,0,10">
|
||||
<TextBlock x:Name="PriceSuggestedText"
|
||||
FontSize="38" FontWeight="Bold"
|
||||
Foreground="White"/>
|
||||
</Border>
|
||||
|
||||
<!-- Min · Suggested · Max visual bar -->
|
||||
<Grid x:Name="PriceRangeBar" Margin="0,0,0,12" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<!-- Connecting line -->
|
||||
<Border Grid.Row="0" Height="2" Margin="12,0"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray7}"
|
||||
CornerRadius="1"/>
|
||||
<!-- Three dots + labels -->
|
||||
<Grid Grid.Row="0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Ellipse Grid.Column="0" Width="10" Height="10"
|
||||
Fill="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Ellipse Grid.Column="2" Width="14" Height="14"
|
||||
Fill="{DynamicResource MahApps.Brushes.Accent}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Ellipse Grid.Column="4" Width="10" Height="10"
|
||||
Fill="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<!-- Labels row -->
|
||||
<Grid Grid.Row="1" Margin="0,4,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center">
|
||||
<TextBlock Text="MIN" FontSize="9" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock x:Name="PriceMinText" FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" HorizontalAlignment="Center">
|
||||
<TextBlock Text="SUGGESTED" FontSize="9" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" HorizontalAlignment="Center">
|
||||
<TextBlock Text="MAX" FontSize="9" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||
HorizontalAlignment="Center"/>
|
||||
<TextBlock x:Name="PriceMaxText" FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- Editable price override -->
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Override price:" FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<mah:NumericUpDown x:Name="PriceOverride"
|
||||
Width="110" Height="32"
|
||||
Minimum="0" Maximum="99999"
|
||||
StringFormat="F2" Interval="0.5"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Price reasoning -->
|
||||
<TextBlock x:Name="PriceReasoningText"
|
||||
FontSize="11" FontStyle="Italic"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,8,0,0"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
Visibility="Collapsed"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Title -->
|
||||
<Border Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8"
|
||||
HorizontalAlignment="Stretch">
|
||||
<iconPacks:PackIconMaterial Kind="TagOutline" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="LISTING TITLE" Style="{StaticResource SectionHeading}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button x:Name="CopyTitleBtn"
|
||||
Style="{StaticResource CopyButton}"
|
||||
Click="CopyTitle_Click"
|
||||
Margin="8,0,0,0"
|
||||
ToolTip="Copy title to clipboard">
|
||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="TitleBox"
|
||||
MaxLength="80"
|
||||
mah:TextBoxHelper.Watermark="Listing title (max 80 chars)"
|
||||
TextChanged="TitleBox_TextChanged"/>
|
||||
<TextBlock x:Name="TitleCount" Text="0 / 80" FontSize="10"
|
||||
HorizontalAlignment="Right" Margin="0,3,0,0"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Description -->
|
||||
<Border Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<iconPacks:PackIconMaterial Kind="TextBox" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="DESCRIPTION" Style="{StaticResource SectionHeading}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button x:Name="CopyDescBtn"
|
||||
Style="{StaticResource CopyButton}"
|
||||
Click="CopyDescription_Click"
|
||||
Margin="8,0,0,0"
|
||||
ToolTip="Copy description to clipboard">
|
||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="DescriptionBox"
|
||||
Height="180" AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
Style="{DynamicResource MahApps.Styles.TextBox}"
|
||||
FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Actions + toast overlay -->
|
||||
<Grid Margin="0,4,0,16" ClipToBounds="False">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="UseDetailsBtn"
|
||||
Content="Use for New Listing →"
|
||||
Click="UseDetails_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
||||
Height="36" Padding="16,0" FontSize="13" FontWeight="SemiBold"/>
|
||||
<Button x:Name="SaveListingBtn"
|
||||
Click="SaveListing_Click"
|
||||
Margin="8,0,0,0"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="36" Padding="14,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
x:Name="SaveIcon"/>
|
||||
<iconPacks:PackIconMaterial Kind="BookmarkCheck" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
x:Name="SavedIcon" Visibility="Collapsed"
|
||||
Foreground="LimeGreen"/>
|
||||
<TextBlock x:Name="SaveBtnText" Text="Save Listing"
|
||||
VerticalAlignment="Center" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="ReAnalyseBtn"
|
||||
Click="ReAnalyse_Click"
|
||||
Margin="8,0,0,0"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="36" Padding="12,0"
|
||||
ToolTip="Re-run AI analysis with the same photo(s)">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Refresh" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Re-analyse" VerticalAlignment="Center" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Content="Analyse Another"
|
||||
Click="AnalyseAnother_Click"
|
||||
Margin="8,0,0,0"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="36" Padding="12,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Save confirmation toast -->
|
||||
<Border x:Name="SaveToast"
|
||||
VerticalAlignment="Bottom"
|
||||
HorizontalAlignment="Left"
|
||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
||||
CornerRadius="6"
|
||||
Padding="14,8"
|
||||
Margin="0,0,0,-48"
|
||||
Visibility="Collapsed"
|
||||
IsHitTestVisible="False">
|
||||
<Border.RenderTransform>
|
||||
<TranslateTransform x:Name="ToastTranslate" Y="40"/>
|
||||
</Border.RenderTransform>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Check" Width="14" Height="14"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||
Foreground="White"/>
|
||||
<TextBlock Text="Saved to Saved Listings"
|
||||
FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="White" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
589
EbayListingTool/Views/PhotoAnalysisView.xaml.cs
Normal file
589
EbayListingTool/Views/PhotoAnalysisView.xaml.cs
Normal file
@@ -0,0 +1,589 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using EbayListingTool.Models;
|
||||
using EbayListingTool.Services;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace EbayListingTool.Views;
|
||||
|
||||
public partial class PhotoAnalysisView : UserControl
|
||||
{
|
||||
private AiAssistantService? _aiService;
|
||||
private SavedListingsService? _savedService;
|
||||
private List<string> _currentImagePaths = new();
|
||||
private PhotoAnalysisResult? _lastResult;
|
||||
private int _activePhotoIndex = 0;
|
||||
private DispatcherTimer? _saveBtnTimer; // M1: field so we can stop it on Unloaded
|
||||
private DispatcherTimer? _holdTimer; // Q2: field so we can stop it on Unloaded
|
||||
|
||||
private const int MaxPhotos = 4;
|
||||
|
||||
// Loading step cycling
|
||||
private readonly DispatcherTimer _loadingTimer;
|
||||
private int _loadingStep;
|
||||
private static readonly string[] LoadingSteps =
|
||||
[
|
||||
"Examining the photo\u2026",
|
||||
"Identifying the item\u2026",
|
||||
"Researching eBay prices\u2026",
|
||||
"Writing description\u2026"
|
||||
];
|
||||
|
||||
// Event raised when user clicks "Use for New Listing" — Q1: passes all loaded photos
|
||||
public event Action<PhotoAnalysisResult, IReadOnlyList<string>, decimal>? UseDetailsRequested;
|
||||
|
||||
public PhotoAnalysisView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||
_loadingTimer.Tick += LoadingTimer_Tick;
|
||||
|
||||
// M1 / Q2: stop timers when control is unloaded to avoid memory leaks
|
||||
Unloaded += (_, _) => { _saveBtnTimer?.Stop(); _holdTimer?.Stop(); };
|
||||
|
||||
// Keep photo clip geometry in sync with container size
|
||||
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
|
||||
}
|
||||
|
||||
public void Initialise(AiAssistantService aiService, SavedListingsService savedService)
|
||||
{
|
||||
_aiService = aiService;
|
||||
_savedService = savedService;
|
||||
}
|
||||
|
||||
// ---- Photo clip geometry sync ----
|
||||
|
||||
private void PhotoPreviewContainer_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||
{
|
||||
PhotoClip.Rect = new System.Windows.Rect(0, 0, e.NewSize.Width, e.NewSize.Height);
|
||||
}
|
||||
|
||||
// ---- Drop zone ----
|
||||
|
||||
private void DropZone_DragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
|
||||
? DragDropEffects.Copy : DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void DropZone_DragEnter(object sender, DragEventArgs e)
|
||||
{
|
||||
// Solid accent border on drag-enter
|
||||
DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Accent");
|
||||
DropBorderRect.StrokeDashArray = null;
|
||||
DropBorderRect.StrokeThickness = 2.5;
|
||||
}
|
||||
|
||||
private void DropZone_DragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Gray6");
|
||||
DropBorderRect.StrokeDashArray = new DoubleCollection([6, 4]);
|
||||
DropBorderRect.StrokeThickness = 2;
|
||||
}
|
||||
|
||||
private void DropZone_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
DropZone_DragLeave(sender, e);
|
||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||
foreach (var file in files.Where(IsImageFile))
|
||||
{
|
||||
LoadPhoto(file);
|
||||
if (_currentImagePaths.Count >= MaxPhotos) break;
|
||||
}
|
||||
}
|
||||
|
||||
private void DropZone_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
var dlg = new OpenFileDialog
|
||||
{
|
||||
Title = "Select photo(s) of the item",
|
||||
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp|All files|*.*",
|
||||
Multiselect = true
|
||||
};
|
||||
if (dlg.ShowDialog() != true) return;
|
||||
foreach (var file in dlg.FileNames)
|
||||
{
|
||||
LoadPhoto(file);
|
||||
if (_currentImagePaths.Count >= MaxPhotos) break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearPhoto_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_currentImagePaths.Clear();
|
||||
_activePhotoIndex = 0;
|
||||
PhotoPreview.Source = null;
|
||||
PhotoPreviewContainer.Visibility = Visibility.Collapsed;
|
||||
ClearPhotoBtn.Visibility = Visibility.Collapsed;
|
||||
DropHint.Visibility = Visibility.Visible;
|
||||
PhotoFilename.Text = "";
|
||||
AnalyseBtn.IsEnabled = false;
|
||||
|
||||
UpdateThumbStrip();
|
||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||||
ResultsPanel.Opacity = 0;
|
||||
IdlePanel.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="path"/> to the photo list (up to MaxPhotos).
|
||||
/// The preview always shows the most recently added image.
|
||||
/// </summary>
|
||||
private void LoadPhoto(string path)
|
||||
{
|
||||
if (_currentImagePaths.Contains(path)) return;
|
||||
if (_currentImagePaths.Count >= MaxPhotos)
|
||||
{
|
||||
MessageBox.Show($"You can add up to {MaxPhotos} photos. Remove one before adding more.",
|
||||
"Photo limit reached", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_currentImagePaths.Add(path);
|
||||
|
||||
var bmp = new BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
|
||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bmp.DecodePixelWidth = 600;
|
||||
bmp.EndInit();
|
||||
bmp.Freeze(); // M2: cross-thread safe, reduces GC pressure
|
||||
|
||||
PhotoPreview.Source = bmp;
|
||||
PhotoPreviewContainer.Visibility = Visibility.Visible;
|
||||
ClearPhotoBtn.Visibility = Visibility.Visible;
|
||||
DropHint.Visibility = Visibility.Collapsed;
|
||||
|
||||
_activePhotoIndex = _currentImagePaths.Count - 1;
|
||||
|
||||
UpdatePhotoFilenameLabel();
|
||||
UpdateThumbStrip();
|
||||
AnalyseBtn.IsEnabled = true;
|
||||
|
||||
// Collapse results so user re-analyses after adding photos
|
||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||||
ResultsPanel.Opacity = 0;
|
||||
IdlePanel.Visibility = Visibility.Visible;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_currentImagePaths.Remove(path);
|
||||
MessageBox.Show($"Could not load image: {ex.Message}", "Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePhotoFilenameLabel()
|
||||
{
|
||||
PhotoFilename.Text = _currentImagePaths.Count switch
|
||||
{
|
||||
0 => "",
|
||||
1 => Path.GetFileName(_currentImagePaths[0]),
|
||||
_ => $"{_currentImagePaths.Count} photos loaded"
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Analyse ----
|
||||
|
||||
private async void Analyse_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_aiService == null || _currentImagePaths.Count == 0) return;
|
||||
|
||||
SetAnalysing(true);
|
||||
try
|
||||
{
|
||||
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
|
||||
_lastResult = result;
|
||||
ShowResults(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetAnalysing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-analyse simply repeats the same call — idempotent by design
|
||||
private void ReAnalyse_Click(object sender, RoutedEventArgs e)
|
||||
=> Analyse_Click(sender, e);
|
||||
|
||||
private void ShowResults(PhotoAnalysisResult r)
|
||||
{
|
||||
IdlePanel.Visibility = Visibility.Collapsed;
|
||||
LoadingPanel.Visibility = Visibility.Collapsed;
|
||||
ResultsPanel.Visibility = Visibility.Visible;
|
||||
|
||||
// Item identification
|
||||
ItemNameText.Text = r.ItemName;
|
||||
|
||||
var brandModel = string.Join(" \u00b7 ",
|
||||
new[] { r.Brand, r.Model }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
if (!string.IsNullOrWhiteSpace(brandModel))
|
||||
{
|
||||
BrandModelText.Text = brandModel;
|
||||
BrandPill.Visibility = Visibility.Visible;
|
||||
}
|
||||
else
|
||||
{
|
||||
BrandPill.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
ConditionText.Text = r.ConditionNotes;
|
||||
|
||||
// Confidence badge
|
||||
ConfidenceBadge.Visibility = Visibility.Collapsed;
|
||||
if (!string.IsNullOrWhiteSpace(r.IdentificationConfidence))
|
||||
{
|
||||
ConfidenceText.Text = r.IdentificationConfidence;
|
||||
ConfidenceBadge.Background = r.IdentificationConfidence.ToLower() switch
|
||||
{
|
||||
"high" => new SolidColorBrush(Color.FromRgb(34, 139, 34)),
|
||||
"medium" => new SolidColorBrush(Color.FromRgb(210, 140, 0)),
|
||||
_ => new SolidColorBrush(Color.FromRgb(192, 0, 0))
|
||||
};
|
||||
ConfidenceBadge.Visibility = Visibility.Visible;
|
||||
}
|
||||
ConfidenceNotesText.Text = r.ConfidenceNotes;
|
||||
ConfidenceNotesText.Visibility = string.IsNullOrWhiteSpace(r.ConfidenceNotes)
|
||||
? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
// Price badge
|
||||
PriceSuggestedText.Text = r.PriceSuggested > 0 ? $"\u00a3{r.PriceSuggested:F2}" : "\u2014";
|
||||
|
||||
// Price range bar
|
||||
if (r.PriceMin > 0 && r.PriceMax > 0)
|
||||
{
|
||||
PriceMinText.Text = $"\u00a3{r.PriceMin:F2}";
|
||||
PriceMaxText.Text = $"\u00a3{r.PriceMax:F2}";
|
||||
PriceRangeBar.Visibility = Visibility.Visible;
|
||||
}
|
||||
else
|
||||
{
|
||||
PriceRangeBar.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
PriceOverride.Value = (double)r.PriceSuggested;
|
||||
|
||||
// Price reasoning
|
||||
PriceReasoningText.Text = r.PriceReasoning;
|
||||
PriceReasoningText.Visibility = string.IsNullOrWhiteSpace(r.PriceReasoning)
|
||||
? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
// Editable fields
|
||||
TitleBox.Text = r.Title;
|
||||
DescriptionBox.Text = r.Description;
|
||||
|
||||
// Animate results in
|
||||
var sb = (Storyboard)FindResource("ResultsReveal");
|
||||
sb.Begin(this);
|
||||
}
|
||||
|
||||
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
var len = TitleBox.Text.Length;
|
||||
TitleCount.Text = $"{len} / 80";
|
||||
TitleCount.Foreground = len > 75
|
||||
? Brushes.OrangeRed
|
||||
: (Brush)FindResource("MahApps.Brushes.Gray5");
|
||||
}
|
||||
|
||||
private void UseDetails_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_lastResult == null) return;
|
||||
|
||||
// Copy any edits back into result before passing on
|
||||
_lastResult.Title = TitleBox.Text;
|
||||
_lastResult.Description = DescriptionBox.Text;
|
||||
|
||||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
||||
UseDetailsRequested?.Invoke(_lastResult, _currentImagePaths, price); // Q1: pass all photos
|
||||
|
||||
// Switch to New Listing tab
|
||||
if (Window.GetWindow(this) is MainWindow mw)
|
||||
mw.SwitchToNewListingTab();
|
||||
|
||||
GetWindow()?.SetStatus($"Details loaded for: {_lastResult.Title}");
|
||||
}
|
||||
|
||||
private void SaveListing_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_lastResult == null || _savedService == null) return;
|
||||
|
||||
// Use edited title/description if the user changed them
|
||||
var title = TitleBox.Text.Trim();
|
||||
var description = DescriptionBox.Text.Trim();
|
||||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
||||
|
||||
_savedService.Save(title, description, price,
|
||||
_lastResult.CategoryKeyword, _lastResult.ConditionNotes,
|
||||
_currentImagePaths);
|
||||
|
||||
// Brief visual confirmation on the button — M1: use field timer, stop previous if re-saved quickly
|
||||
_saveBtnTimer?.Stop();
|
||||
SaveIcon.Visibility = Visibility.Collapsed;
|
||||
SavedIcon.Visibility = Visibility.Visible;
|
||||
SaveBtnText.Text = "Saved!";
|
||||
|
||||
_saveBtnTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
||||
_saveBtnTimer.Tick += (s, _) =>
|
||||
{
|
||||
_saveBtnTimer?.Stop();
|
||||
SaveIcon.Visibility = Visibility.Visible;
|
||||
SavedIcon.Visibility = Visibility.Collapsed;
|
||||
SaveBtnText.Text = "Save Listing";
|
||||
};
|
||||
_saveBtnTimer.Start();
|
||||
|
||||
ShowSaveToast();
|
||||
|
||||
// Notify main window to refresh the gallery
|
||||
(Window.GetWindow(this) as MainWindow)?.RefreshSavedListings();
|
||||
GetWindow()?.SetStatus($"Saved: {title}");
|
||||
}
|
||||
|
||||
private void AnalyseAnother_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_currentImagePaths.Clear();
|
||||
_lastResult = null;
|
||||
_activePhotoIndex = 0;
|
||||
PhotoPreview.Source = null;
|
||||
PhotoPreviewContainer.Visibility = Visibility.Collapsed;
|
||||
ClearPhotoBtn.Visibility = Visibility.Collapsed;
|
||||
DropHint.Visibility = Visibility.Visible;
|
||||
PhotoFilename.Text = "";
|
||||
AnalyseBtn.IsEnabled = false;
|
||||
UpdateThumbStrip();
|
||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||||
ResultsPanel.Opacity = 0;
|
||||
IdlePanel.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
// ---- Thumb strip ----
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the thumbnail strip from <see cref="_currentImagePaths"/>.
|
||||
/// Shows/hides the strip based on whether 2+ photos are loaded.
|
||||
/// </summary>
|
||||
private void UpdateThumbStrip()
|
||||
{
|
||||
PhotoThumbStrip.Children.Clear();
|
||||
|
||||
if (_currentImagePaths.Count < 2)
|
||||
{
|
||||
ThumbStripScroller.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
ThumbStripScroller.Visibility = Visibility.Visible;
|
||||
|
||||
var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
|
||||
var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
|
||||
|
||||
for (int i = 0; i < _currentImagePaths.Count; i++)
|
||||
{
|
||||
var index = i; // capture for closure
|
||||
var path = _currentImagePaths[i];
|
||||
|
||||
BitmapImage? thumb = null;
|
||||
try
|
||||
{
|
||||
thumb = new BitmapImage();
|
||||
thumb.BeginInit();
|
||||
thumb.UriSource = new Uri(path, UriKind.Absolute); // W1
|
||||
thumb.CacheOption = BitmapCacheOption.OnLoad;
|
||||
thumb.DecodePixelWidth = 80;
|
||||
thumb.EndInit();
|
||||
thumb.Freeze(); // M2
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip thumbnails that fail to load
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isActive = (index == _activePhotoIndex);
|
||||
|
||||
var img = new Image
|
||||
{
|
||||
Source = thumb,
|
||||
Width = 60,
|
||||
Height = 60,
|
||||
Stretch = Stretch.UniformToFill
|
||||
};
|
||||
RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.HighQuality);
|
||||
// Clip image to rounded rect
|
||||
img.Clip = new System.Windows.Media.RectangleGeometry(
|
||||
new System.Windows.Rect(0, 0, 60, 60), 4, 4);
|
||||
|
||||
var border = new Border
|
||||
{
|
||||
Width = 64,
|
||||
Height = 64,
|
||||
Margin = new Thickness(3),
|
||||
CornerRadius = new CornerRadius(5),
|
||||
BorderThickness = new Thickness(isActive ? 2.5 : 1.5),
|
||||
BorderBrush = isActive ? accentBrush : neutralBrush,
|
||||
Background = System.Windows.Media.Brushes.Transparent,
|
||||
Cursor = System.Windows.Input.Cursors.Hand,
|
||||
Child = img
|
||||
};
|
||||
|
||||
border.MouseLeftButtonUp += (_, _) =>
|
||||
{
|
||||
_activePhotoIndex = index;
|
||||
|
||||
// Load that photo into main preview
|
||||
try
|
||||
{
|
||||
var bmp = new BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.UriSource = new Uri(_currentImagePaths[index], UriKind.Absolute); // W1
|
||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bmp.DecodePixelWidth = 600;
|
||||
bmp.EndInit();
|
||||
bmp.Freeze(); // M2
|
||||
PhotoPreview.Source = bmp;
|
||||
}
|
||||
catch { /* silently ignore */ }
|
||||
|
||||
// Q3: full rebuild avoids index-desync when thumbnails skipped on load error
|
||||
UpdateThumbStrip();
|
||||
};
|
||||
|
||||
PhotoThumbStrip.Children.Add(border);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates only the border highlights on the existing thumb strip children
|
||||
/// after the active index changes, avoiding a full thumbnail reload.
|
||||
/// </summary>
|
||||
private void UpdateThumbStripHighlight()
|
||||
{
|
||||
var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
|
||||
var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
|
||||
|
||||
int childIndex = 0;
|
||||
for (int i = 0; i < _currentImagePaths.Count; i++)
|
||||
{
|
||||
if (childIndex >= PhotoThumbStrip.Children.Count) break;
|
||||
if (PhotoThumbStrip.Children[childIndex] is Border b)
|
||||
{
|
||||
bool isActive = (i == _activePhotoIndex);
|
||||
b.BorderBrush = isActive ? accentBrush : neutralBrush;
|
||||
b.BorderThickness = new Thickness(isActive ? 2.5 : 1.5);
|
||||
}
|
||||
childIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Save toast ----
|
||||
|
||||
private bool _toastAnimating = false;
|
||||
|
||||
private void ShowSaveToast()
|
||||
{
|
||||
if (_toastAnimating) return;
|
||||
_toastAnimating = true;
|
||||
|
||||
SaveToast.Visibility = Visibility.Visible;
|
||||
|
||||
// Slide in: Y from +40 to 0
|
||||
var slideIn = new DoubleAnimation(40, 0, new Duration(TimeSpan.FromMilliseconds(220)))
|
||||
{
|
||||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||
};
|
||||
|
||||
// After 2.5 s total: slide out Y from 0 to +40, then hide
|
||||
slideIn.Completed += (_, _) =>
|
||||
{
|
||||
_holdTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(2500 - 220) }; // Q2: field
|
||||
_holdTimer.Tick += (s2, _) =>
|
||||
{
|
||||
_holdTimer.Stop();
|
||||
var slideOut = new DoubleAnimation(0, 40, new Duration(TimeSpan.FromMilliseconds(180)))
|
||||
{
|
||||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
|
||||
};
|
||||
slideOut.Completed += (_, _) =>
|
||||
{
|
||||
SaveToast.Visibility = Visibility.Collapsed;
|
||||
_toastAnimating = false;
|
||||
};
|
||||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut);
|
||||
};
|
||||
_holdTimer.Start();
|
||||
};
|
||||
|
||||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideIn);
|
||||
}
|
||||
|
||||
// ---- Copy buttons ----
|
||||
|
||||
private void CopyTitle_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(TitleBox.Text))
|
||||
Clipboard.SetText(TitleBox.Text);
|
||||
}
|
||||
|
||||
private void CopyDescription_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DescriptionBox.Text))
|
||||
Clipboard.SetText(DescriptionBox.Text);
|
||||
}
|
||||
|
||||
// ---- Loading step cycling ----
|
||||
|
||||
private void LoadingTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
_loadingStep = (_loadingStep + 1) % LoadingSteps.Length;
|
||||
LoadingStepText.Text = LoadingSteps[_loadingStep];
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private void SetAnalysing(bool busy)
|
||||
{
|
||||
AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||
AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||
AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI";
|
||||
AnalyseBtn.IsEnabled = !busy;
|
||||
IsEnabled = !busy;
|
||||
|
||||
if (busy)
|
||||
{
|
||||
IdlePanel.Visibility = Visibility.Collapsed;
|
||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
||||
LoadingPanel.Visibility = Visibility.Visible;
|
||||
_loadingStep = 0;
|
||||
LoadingStepText.Text = LoadingSteps[0];
|
||||
_loadingTimer.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_loadingTimer.Stop();
|
||||
LoadingPanel.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsImageFile(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLower();
|
||||
return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
|
||||
}
|
||||
|
||||
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
|
||||
}
|
||||
316
EbayListingTool/Views/SavedListingsView.xaml
Normal file
316
EbayListingTool/Views/SavedListingsView.xaml
Normal file
@@ -0,0 +1,316 @@
|
||||
<UserControl x:Class="EbayListingTool.Views.SavedListingsView"
|
||||
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">
|
||||
|
||||
<UserControl.Resources>
|
||||
|
||||
<Style x:Key="CardBorder" TargetType="Border">
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
|
||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SelectedCardBorder" TargetType="Border" BasedOn="{StaticResource CardBorder}">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CardActionBtn" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||
<Setter Property="Height" Value="28"/>
|
||||
<Setter Property="Padding" Value="10,0"/>
|
||||
<Setter Property="FontSize" Value="11"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DetailLabel" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<Setter Property="Margin" Value="0,10,0,3"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SearchBox" TargetType="TextBox"
|
||||
BasedOn="{StaticResource MahApps.Styles.TextBox}">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Height" Value="30"/>
|
||||
</Style>
|
||||
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="320" MinWidth="220"/>
|
||||
<ColumnDefinition Width="4"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ================================================================
|
||||
LEFT — Listings list
|
||||
================================================================ -->
|
||||
<Grid Grid.Column="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header -->
|
||||
<Border Grid.Row="0" Padding="14,10"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="14" Height="14"
|
||||
Margin="0,0,7,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock x:Name="ListingCountText" Text="0 saved listings"
|
||||
FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="OpenExportsDirBtn"
|
||||
Click="OpenExportsDir_Click"
|
||||
Style="{StaticResource CardActionBtn}"
|
||||
ToolTip="Open exports folder in Explorer">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="12" Height="12"
|
||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Open folder" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Search/filter box -->
|
||||
<Border Grid.Row="1" Padding="10,8,10,6"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray9}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<iconPacks:PackIconMaterial Grid.Column="0" Kind="Magnify"
|
||||
Width="13" Height="13"
|
||||
Margin="0,0,7,0"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
<TextBox Grid.Column="1" x:Name="SearchBox"
|
||||
Style="{StaticResource SearchBox}"
|
||||
mah:TextBoxHelper.Watermark="Filter listings…"
|
||||
mah:TextBoxHelper.ClearTextButton="True"
|
||||
TextChanged="SearchBox_TextChanged"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Card list -->
|
||||
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
|
||||
Padding="10,8">
|
||||
<Grid>
|
||||
<!-- Empty state for no saved listings -->
|
||||
<StackPanel x:Name="EmptyCardState"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,40,0,0"
|
||||
Visibility="Collapsed">
|
||||
<Border Width="64" Height="64"
|
||||
CornerRadius="32"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,16"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray9}">
|
||||
<iconPacks:PackIconMaterial Kind="BookmarkPlusOutline"
|
||||
Width="32" Height="32"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
</Border>
|
||||
<TextBlock Text="No saved listings yet"
|
||||
FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,0,0,6"/>
|
||||
<TextBlock Text="Analyse a photo and click Save Listing"
|
||||
FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||
HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
MaxWidth="200"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- No filter results state -->
|
||||
<StackPanel x:Name="EmptyFilterState"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,40,0,0"
|
||||
Visibility="Collapsed">
|
||||
<iconPacks:PackIconMaterial Kind="MagnifyClose"
|
||||
Width="36" Height="36"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||
Margin="0,0,0,12"/>
|
||||
<TextBlock Text="No listings match your search"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="CardPanel"/>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- Splitter -->
|
||||
<GridSplitter Grid.Column="1" Width="4" HorizontalAlignment="Stretch"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray8}"/>
|
||||
|
||||
<!-- ================================================================
|
||||
RIGHT — Detail panel
|
||||
================================================================ -->
|
||||
<Grid Grid.Column="2">
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="EmptyDetail" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="48" Height="48"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
||||
Margin="0,0,0,14"/>
|
||||
<TextBlock Text="Select a saved listing" FontSize="14"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Detail content -->
|
||||
<ScrollViewer x:Name="DetailPanel" Visibility="Collapsed" Opacity="0"
|
||||
VerticalScrollBarVisibility="Auto" Padding="18,14">
|
||||
<StackPanel>
|
||||
|
||||
<!-- Title + price row -->
|
||||
<Grid Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock x:Name="DetailTitle" Grid.Column="0"
|
||||
FontSize="17" FontWeight="Bold" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
|
||||
<Border Grid.Column="1"
|
||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
||||
CornerRadius="6" Padding="10,4" Margin="10,0,0,0"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock x:Name="DetailPrice"
|
||||
FontSize="16" FontWeight="Bold" Foreground="White"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Meta row: category · date -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
|
||||
<iconPacks:PackIconMaterial Kind="Tag" Width="11" Height="11"
|
||||
Margin="0,0,4,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
<TextBlock x:Name="DetailCategory" FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text=" · " FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||
VerticalAlignment="Center"/>
|
||||
<iconPacks:PackIconMaterial Kind="ClockOutline" Width="11" Height="11"
|
||||
Margin="0,0,4,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
<TextBlock x:Name="DetailDate" FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Photos strip -->
|
||||
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}"/>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
Margin="0,0,0,4">
|
||||
<WrapPanel x:Name="DetailPhotosPanel" Orientation="Horizontal"/>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Condition notes -->
|
||||
<TextBlock x:Name="DetailConditionRow" Visibility="Collapsed">
|
||||
<TextBlock.Inlines>
|
||||
<Run FontWeight="SemiBold" Text="Condition: "/>
|
||||
<Run x:Name="DetailCondition"/>
|
||||
</TextBlock.Inlines>
|
||||
</TextBlock>
|
||||
|
||||
<!-- Description -->
|
||||
<TextBlock Text="DESCRIPTION" Style="{StaticResource DetailLabel}"/>
|
||||
<Border BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray10}"
|
||||
Margin="0,0,0,14">
|
||||
<TextBlock x:Name="DetailDescription"
|
||||
TextWrapping="Wrap" FontSize="12"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
|
||||
</Border>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Click="OpenFolderDetail_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
||||
Height="34" Padding="14,0" Margin="0,0,8,6">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Open Export Folder" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Click="CopyTitle_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="34" Padding="12,0" Margin="0,0,8,6">
|
||||
<Button.Content>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Copy Title" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button.Content>
|
||||
</Button>
|
||||
<Button Click="CopyDescription_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="34" Padding="12,0" Margin="0,0,8,6">
|
||||
<Button.Content>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Copy Description" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button.Content>
|
||||
</Button>
|
||||
<Button Click="DeleteListing_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="34" Padding="12,0" Margin="0,0,0,6">
|
||||
<Button.Content>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"
|
||||
Foreground="OrangeRed"/>
|
||||
<TextBlock Text="Delete" VerticalAlignment="Center"
|
||||
Foreground="OrangeRed"/>
|
||||
</StackPanel>
|
||||
</Button.Content>
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
372
EbayListingTool/Views/SavedListingsView.xaml.cs
Normal file
372
EbayListingTool/Views/SavedListingsView.xaml.cs
Normal file
@@ -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<SavedListing> 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();
|
||||
}
|
||||
}
|
||||
566
EbayListingTool/Views/SingleItemView.xaml
Normal file
566
EbayListingTool/Views/SingleItemView.xaml
Normal file
@@ -0,0 +1,566 @@
|
||||
<UserControl x:Class="EbayListingTool.Views.SingleItemView"
|
||||
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"
|
||||
Loaded="UserControl_Loaded">
|
||||
|
||||
<UserControl.Resources>
|
||||
|
||||
<!-- Section card — subtle bordered/shaded panel wrapping a group of fields -->
|
||||
<Style x:Key="SectionCard" TargetType="Border">
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="4"/>
|
||||
<Setter Property="Padding" Value="14,12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
|
||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Upper-case accent section heading used inside each card -->
|
||||
<Style x:Key="SectionHeading" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="10"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<Setter Property="Margin" Value="0,0,0,0"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- Standard field label inside a card -->
|
||||
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray2}"/>
|
||||
</Style>
|
||||
|
||||
<!-- Small red asterisk for required fields -->
|
||||
<Style x:Key="RequiredAsterisk" TargetType="TextBlock">
|
||||
<Setter Property="Text" Value=" *"/>
|
||||
<Setter Property="FontWeight" Value="Bold"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Foreground" Value="#E53935"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
||||
<Setter Property="ToolTip" Value="Required"/>
|
||||
</Style>
|
||||
|
||||
<!-- AI buttons: indigo-to-violet gradient, icon + label -->
|
||||
<Style x:Key="AiButton" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||
<Setter Property="Height" Value="28"/>
|
||||
<Setter Property="Padding" Value="8,0"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background">
|
||||
<Setter.Value>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#8B5CF6" Offset="0"/>
|
||||
<GradientStop Color="#6366F1" Offset="1"/>
|
||||
</LinearGradientBrush>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Primary post/action button -->
|
||||
<Style x:Key="PostButton" TargetType="Button"
|
||||
BasedOn="{StaticResource MahApps.Styles.Button.Square.Accent}">
|
||||
<Setter Property="Height" Value="36"/>
|
||||
<Setter Property="Padding" Value="20,0"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid Margin="16,12,16,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="290"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- ================================================================
|
||||
LEFT COLUMN — form fields grouped into section cards
|
||||
================================================================ -->
|
||||
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
|
||||
<!-- LISTING DETAILS -->
|
||||
<Border Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
|
||||
<!-- Card header row -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<iconPacks:PackIconMaterial Kind="TagOutline" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="LISTING DETAILS" Style="{StaticResource SectionHeading}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Title label row with required asterisk -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Title"/>
|
||||
<TextBlock Style="{StaticResource RequiredAsterisk}"/>
|
||||
</StackPanel>
|
||||
<!-- Title + AI Title button -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="TitleBox"
|
||||
mah:TextBoxHelper.Watermark="Item title (max 80 characters)"
|
||||
MaxLength="80"
|
||||
TextChanged="TitleBox_TextChanged"/>
|
||||
<Button Grid.Column="1" Margin="6,0,0,0"
|
||||
Style="{StaticResource AiButton}"
|
||||
Click="AiTitle_Click"
|
||||
ToolTip="Ask Claude to write a keyword-rich eBay title based on your item details">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<mah:ProgressRing x:Name="TitleSpinner"
|
||||
Width="11" Height="11" Margin="0,0,4,0"
|
||||
Foreground="White" Visibility="Collapsed"/>
|
||||
<iconPacks:PackIconMaterial x:Name="TitleAiIcon"
|
||||
Kind="AutoFix" Width="12" Height="12"
|
||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="AI Title" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- Visual character-count progress bar -->
|
||||
<Grid Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Track -->
|
||||
<Border Grid.Column="0" Height="4" CornerRadius="2"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<!-- Fill — width set in code-behind via TitleCountBar.Width -->
|
||||
<Border x:Name="TitleCountBar" HorizontalAlignment="Left"
|
||||
Height="4" CornerRadius="2" Width="0"
|
||||
Background="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
</Border>
|
||||
<TextBlock x:Name="TitleCount" Grid.Column="1"
|
||||
Text="0 / 80" FontSize="10"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Category label row with required asterisk -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Category" Margin="0,0,0,4"/>
|
||||
<TextBlock Style="{StaticResource RequiredAsterisk}"/>
|
||||
</StackPanel>
|
||||
<!-- Category search -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="CategoryBox"
|
||||
mah:TextBoxHelper.Watermark="Start typing to search categories..."
|
||||
TextChanged="CategoryBox_TextChanged"
|
||||
KeyDown="CategoryBox_KeyDown"/>
|
||||
<Border Grid.Column="1" Margin="8,0,0,0" CornerRadius="3"
|
||||
Padding="6,2" VerticalAlignment="Center"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray8}">
|
||||
<TextBlock x:Name="CategoryIdLabel" FontSize="11"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||
Text="no category"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<!-- Category dropdown suggestions -->
|
||||
<ListBox x:Name="CategorySuggestionsList" MaxHeight="140"
|
||||
SelectionChanged="CategorySuggestionsList_SelectionChanged"
|
||||
Visibility="Collapsed" Margin="0,2,0,0"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
|
||||
BorderThickness="1">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding CategoryPath}" FontSize="12" Padding="4,3"/>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Condition + Format -->
|
||||
<Grid Margin="0,10,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Condition"/>
|
||||
<ComboBox x:Name="ConditionBox"
|
||||
SelectionChanged="ConditionBox_SelectionChanged">
|
||||
<ComboBoxItem Content="New" Tag="New"
|
||||
ToolTip="Brand new, unopened (eBay: New)"/>
|
||||
<ComboBoxItem Content="Open Box" Tag="OpenBox"
|
||||
ToolTip="Opened but unused (eBay: Open box)"/>
|
||||
<ComboBoxItem Content="Refurbished" Tag="Refurbished"
|
||||
ToolTip="Professionally restored (eBay: Seller refurbished)"/>
|
||||
<ComboBoxItem Content="Used" Tag="Used" IsSelected="True"
|
||||
ToolTip="Previously used, working (eBay: Used)"/>
|
||||
<ComboBoxItem Content="For Parts / Not Working" Tag="ForParts"
|
||||
ToolTip="Not fully working (eBay: For parts or not working)"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Format"/>
|
||||
<ComboBox x:Name="FormatBox">
|
||||
<ComboBoxItem Content="Buy It Now" IsSelected="True"/>
|
||||
<ComboBoxItem Content="Auction"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- DESCRIPTION -->
|
||||
<Border Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="TextBox" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="DESCRIPTION" Style="{StaticResource SectionHeading}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Style="{StaticResource AiButton}"
|
||||
Click="AiDescription_Click"
|
||||
ToolTip="Ask Claude to write a full product description">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<mah:ProgressRing x:Name="DescSpinner"
|
||||
Width="11" Height="11" Margin="0,0,4,0"
|
||||
Foreground="White" Visibility="Collapsed"/>
|
||||
<iconPacks:PackIconMaterial x:Name="DescAiIcon"
|
||||
Kind="AutoFix" Width="12" Height="12"
|
||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="AI Description" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
<TextBox x:Name="DescriptionBox" Height="150" AcceptsReturn="True"
|
||||
TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
|
||||
mah:TextBoxHelper.Watermark="Describe the item, condition, what's included..."
|
||||
Style="{DynamicResource MahApps.Styles.TextBox}"
|
||||
TextChanged="DescriptionBox_TextChanged"/>
|
||||
|
||||
<!-- Description character count progress bar (soft limit 2000) -->
|
||||
<Grid Margin="0,5,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="0" Height="4" CornerRadius="2"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<Border x:Name="DescCountBar" HorizontalAlignment="Left"
|
||||
Height="4" CornerRadius="2" Width="0"
|
||||
Background="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
</Border>
|
||||
<TextBlock x:Name="DescCount" Grid.Column="1"
|
||||
Text="0 / 2000" FontSize="10"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- PRICING & LOGISTICS -->
|
||||
<Border Style="{StaticResource SectionCard}">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="PRICING & LOGISTICS" Style="{StaticResource SectionHeading}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="12"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Price with inline AI button -->
|
||||
<StackPanel Grid.Column="0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Style="{StaticResource FieldLabel}" Text="Price (£)"/>
|
||||
<TextBlock Grid.Column="1" Style="{StaticResource RequiredAsterisk}"/>
|
||||
<Button Grid.Column="3" Style="{StaticResource AiButton}"
|
||||
Click="AiPrice_Click"
|
||||
Height="22" Padding="6,0" FontSize="11"
|
||||
Margin="4,0,0,4"
|
||||
ToolTip="Ask Claude to suggest a competitive eBay UK price">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<mah:ProgressRing x:Name="PriceSpinner"
|
||||
Width="9" Height="9" Margin="0,0,3,0"
|
||||
Foreground="White" Visibility="Collapsed"/>
|
||||
<iconPacks:PackIconMaterial x:Name="PriceAiIcon"
|
||||
Kind="AutoFix" Width="10" Height="10"
|
||||
Margin="0,0,3,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="AI Price" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
<mah:NumericUpDown x:Name="PriceBox" Minimum="0" Maximum="99999"
|
||||
StringFormat="F2" Interval="0.5" Value="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Quantity"/>
|
||||
<mah:NumericUpDown x:Name="QuantityBox" Minimum="1" Maximum="999"
|
||||
Value="1" StringFormat="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="4">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Postage"/>
|
||||
<ComboBox x:Name="PostageBox">
|
||||
<ComboBoxItem Content="Royal Mail 1st Class (~£1.55)" IsSelected="True"/>
|
||||
<ComboBoxItem Content="Royal Mail 2nd Class (~£1.20)"/>
|
||||
<ComboBoxItem Content="Royal Mail Tracked 24 (~£2.90)"/>
|
||||
<ComboBoxItem Content="Royal Mail Tracked 48 (~£2.60)"/>
|
||||
<ComboBoxItem Content="Free Postage"/>
|
||||
<ComboBoxItem Content="Collection Only"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Postcode — narrower input, left-aligned -->
|
||||
<StackPanel Margin="0,10,0,0">
|
||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Item Postcode"/>
|
||||
<TextBox x:Name="PostcodeBox"
|
||||
mah:TextBoxHelper.Watermark="e.g. NR1 1AA"
|
||||
MaxLength="10" Width="150"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- AI price suggestion result -->
|
||||
<Border x:Name="PriceSuggestionPanel" Visibility="Collapsed"
|
||||
CornerRadius="4" Margin="0,12,0,0" Padding="12,10"
|
||||
Background="#1A7C3AED"
|
||||
BorderBrush="#7C3AED" BorderThickness="1">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
||||
<iconPacks:PackIconMaterial Kind="AutoFix" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="#7C3AED"/>
|
||||
<TextBlock Text="AI Price Suggestion" FontWeight="Bold"
|
||||
FontSize="12" Foreground="#7C3AED"/>
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="PriceSuggestionText" TextWrapping="Wrap"
|
||||
FontSize="12" Margin="0,0,0,8"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
|
||||
<Button Content="Use this price" HorizontalAlignment="Left"
|
||||
Click="UseSuggestedPrice_Click"
|
||||
Style="{StaticResource AiButton}"
|
||||
Height="26" Padding="10,0" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ACTION BUTTONS -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,8">
|
||||
<!-- Post: primary accent + spinner overlay -->
|
||||
<Button x:Name="PostBtn" Style="{StaticResource PostButton}"
|
||||
Click="PostListing_Click">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<mah:ProgressRing x:Name="PostSpinner"
|
||||
Width="14" Height="14" Margin="0,0,6,0"
|
||||
Foreground="White" Visibility="Collapsed"/>
|
||||
<iconPacks:PackIconMaterial x:Name="PostIcon"
|
||||
Kind="Send" Width="14" Height="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="SaveDraftBtn" Content="Save Draft"
|
||||
Margin="8,0,0,0" Click="SaveDraft_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="36" Padding="14,0" FontSize="13"/>
|
||||
<Button x:Name="NewListingBtn" Margin="8,0,0,0" Click="NewListing_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="36" Padding="14,0" FontSize="13">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="Refresh" Width="13" Height="13"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Clear" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Posted success banner -->
|
||||
<Border x:Name="SuccessPanel" Visibility="Collapsed"
|
||||
CornerRadius="4" Padding="14,10"
|
||||
Background="#1A4CAF50" BorderBrush="#4CAF50" BorderThickness="1">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="CheckCircle" Width="16" Height="16"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||
Foreground="#4CAF50"/>
|
||||
<TextBlock Text="Posted! " FontWeight="Bold" VerticalAlignment="Center"
|
||||
Foreground="#4CAF50"/>
|
||||
<TextBlock x:Name="ListingUrlText" Foreground="#1565C0"
|
||||
VerticalAlignment="Center"
|
||||
Cursor="Hand" TextDecorations="Underline"
|
||||
MouseLeftButtonUp="ListingUrl_Click"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<Button Content="Copy URL" Height="24" Padding="8,0" FontSize="11"
|
||||
Click="CopyUrl_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
ToolTip="Copy listing URL to clipboard"/>
|
||||
<Button Content="Copy Title" Height="24" Padding="8,0" FontSize="11"
|
||||
Margin="6,0,0,0" Click="CopyTitle_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
ToolTip="Copy listing title to clipboard"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ================================================================
|
||||
RIGHT COLUMN — photos panel
|
||||
================================================================ -->
|
||||
<Grid Grid.Column="2">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header: label + count badge -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="ImageMultiple" Width="13" Height="13"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||
<TextBlock Text="PHOTOS" Style="{StaticResource SectionHeading}"/>
|
||||
</StackPanel>
|
||||
<!-- Photo count badge -->
|
||||
<Border Grid.Column="1" CornerRadius="10" Padding="8,2"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray8}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock x:Name="PhotoCountBadge" Text="0"
|
||||
FontSize="12" FontWeight="Bold"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
|
||||
<TextBlock Text=" / 12" FontSize="12"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Drop zone with dashed border, hover highlight -->
|
||||
<Border Grid.Row="1"
|
||||
x:Name="DropZone"
|
||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
|
||||
BorderThickness="2" CornerRadius="4"
|
||||
MinHeight="220"
|
||||
AllowDrop="True" Drop="Photos_Drop" DragOver="Photos_DragOver"
|
||||
DragEnter="DropZone_DragEnter" DragLeave="DropZone_DragLeave"
|
||||
Background="{DynamicResource MahApps.Brushes.Gray10}">
|
||||
<Border.Resources>
|
||||
<!-- Dashed border via VisualBrush trickery is complex in WPF;
|
||||
we use a solid thin border with hover accent colour instead -->
|
||||
</Border.Resources>
|
||||
<Grid>
|
||||
<!-- Empty-state hint (hidden once photos added) -->
|
||||
<StackPanel x:Name="DropHint" VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
IsHitTestVisible="False">
|
||||
<iconPacks:PackIconMaterial Kind="ImagePlus"
|
||||
Width="40" Height="40"
|
||||
Margin="0,0,0,8"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
||||
<TextBlock Text="Drag & drop photos here"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||
FontSize="12" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="or use Browse below"
|
||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
||||
FontSize="11" HorizontalAlignment="Center" Margin="0,3,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Thumbnails: each built in code-behind as a Grid with hover X overlay -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<WrapPanel x:Name="PhotosPanel" Margin="6"/>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Browse / Clear actions -->
|
||||
<Grid Grid.Row="2" Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button Grid.Column="0" Click="BrowsePhotos_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
||||
Height="30">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="FolderImage" Width="13" Height="13"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Browse..." VerticalAlignment="Center" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="2" Click="ClearPhotos_Click"
|
||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||
Height="30" Padding="10,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13"
|
||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Clear" VerticalAlignment="Center" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
567
EbayListingTool/Views/SingleItemView.xaml.cs
Normal file
567
EbayListingTool/Views/SingleItemView.xaml.cs
Normal file
@@ -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"] ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Pre-fills the form from a Photo Analysis result.</summary>
|
||||
public void PopulateFromAnalysis(PhotoAnalysisResult result, IReadOnlyList<string> 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<string>(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;
|
||||
}
|
||||
14
EbayListingTool/appsettings.json
Normal file
14
EbayListingTool/appsettings.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"Ebay": {
|
||||
"ClientId": "YOUR_EBAY_CLIENT_ID",
|
||||
"ClientSecret": "YOUR_EBAY_CLIENT_SECRET",
|
||||
"RuName": "YOUR_EBAY_RUNAME",
|
||||
"Sandbox": true,
|
||||
"RedirectPort": 8080,
|
||||
"DefaultPostcode": "NR1 1AA"
|
||||
},
|
||||
"OpenRouter": {
|
||||
"ApiKey": "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",
|
||||
"Model": "anthropic/claude-sonnet-4-5"
|
||||
}
|
||||
}
|
||||
4
EbayListingTool/sample-import.csv
Normal file
4
EbayListingTool/sample-import.csv
Normal file
@@ -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,
|
||||
|
Reference in New Issue
Block a user