Initial commit: EbayListingTool WPF application

C# WPF desktop app for creating eBay UK listings with AI-powered
photo analysis. Features: multi-photo vision analysis via OpenRouter
(Claude), local listing save/export, saved listings browser,
single item listing form, bulk import from CSV/Excel, and eBay
OAuth authentication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Foster
2026-04-13 17:33:27 +01:00
commit 9fad0f2ac0
29 changed files with 5908 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Build outputs
bin/
obj/
*.user
*.suo
.vs/
# Config with secrets — never commit
EbayListingTool/appsettings.json
# Rider / JetBrains
.idea/
# OS
.DS_Store
Thumbs.db

22
EbayListingTool.sln Normal file
View 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
View 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>

View 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);
}
}

View 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>

View 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;

View 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; } = "";
}

View 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));
}

View 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;
}

View 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));
}

View 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}" : "";
}

View 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");
}

View 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.");
}
}

View 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;
}

View 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));
}
}

View 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;
}
}

View 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;
}
}

View 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})";
}
}

View 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="FolderOpenOutline" 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>

View 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;
}
}

View 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="CameraSearch" 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>

View 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;
}

View 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 &amp; 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="TagSearchOutline"
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&#10;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 1020 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="ShoppingSearch" 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>

View 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;
}

View 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>

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

View 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 &amp; 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="ImagePlusOutline"
Width="40" Height="40"
Margin="0,0,0,8"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
<TextBlock Text="Drag &amp; 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>

View 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;
}

View 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,
1 Title Description Price Condition CategoryKeyword Quantity PhotoPaths
2 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
3 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
4 LEGO Star Wars Millennium Falcon 75257 Complete set, all pieces present, built once. Box included, instructions included. 55.00 Used Construction Toys 1