27 Commits

Author SHA1 Message Date
cdde3ae195 refactor: unify TryCreate* into one helper, parallel photo upload, static JsonSettings, remove dead ExtractEbayError 2026-04-17 02:59:02 +01:00
c51342f46e fix: correct Icon URI to /EbayListingTool;component/app_icon.ico 2026-04-17 02:52:24 +01:00
b3bb2e704e feat: add application icon (price tag design) 2026-04-17 02:48:20 +01:00
60ed955143 fix: use Inventory API condition enum strings (NEW/USED_VERY_GOOD etc) not numeric IDs 2026-04-17 01:59:55 +01:00
1a70e5f58a fix: use UK_OtherCourier shipping service (UK_RoyalMailSecondClass invalid in sandbox) 2026-04-17 01:57:12 +01:00
53a4594e1c fix: also trigger auto Business Policy setup when policy list is empty (not just on 20403) 2026-04-17 01:54:11 +01:00
d84a61b918 debug: log all eBay setup API responses (opt_in + policy creation) to crash_log.txt 2026-04-17 01:48:07 +01:00
a780ec0089 fix: restore missing _merchantLocationKey field dropped by bad patch 2026-04-17 01:44:35 +01:00
c34a2fd5a5 fix: wire up auto Business Policy setup trigger on 20403 error 2026-04-17 01:42:09 +01:00
1c906c5f6c feat: auto opt-in to Business Policies and create default shipping/payment/returns policies on first post 2026-04-16 19:56:10 +01:00
ded36a27cf feat: show live estimated eBay Final Value Fee (12.8%) below price field 2026-04-16 12:31:31 +01:00
554a280caa fix: show actionable Business Policy setup instructions instead of raw JSON on 400 error 2026-04-16 12:12:43 +01:00
135fd07f54 fix: remove csharp-pro FocusManager.FocusedElement that interfered with StateB text box focus 2026-04-16 11:36:20 +01:00
1533945126 debug: log crash_log.txt with full stack trace on any unhandled exception or Post failure 2026-04-16 11:35:47 +01:00
61a48e8f28 fix: remove csharp-pro KeyboardNavigation overrides causing text box issues 2026-04-16 09:51:17 +01:00
da992c94cd fix: restore drop zone click - add Transparent background to inner Grid, remove IsHitTestVisible from Rectangle 2026-04-16 09:39:30 +01:00
1de957f73e ui: WPF layout, accessibility, and performance improvements 2026-04-16 02:50:11 +01:00
2903035d8c ui: bigger thumbnails (60->96 strip, 72->100 panel), increase font sizes for readability 2026-04-16 02:37:01 +01:00
e936877542 fix: replace mojibake pound signs with unicode escape across all price strings 2026-04-16 02:22:02 +01:00
3b0ed62e7c fix: postage field top margin, cap State B field width to 600px 2026-04-16 02:21:33 +01:00
3cef63a472 fix: DispatcherTimer leak in toast, delete-before-toast ordering in PostDraft 2026-04-16 02:15:30 +01:00
1f7f28b3b9 fix: correct conditionNotes, persist post history, remove redundant Dispatcher.Invoke 2026-04-16 02:13:43 +01:00
299c08248c refactor: remove PhotoAnalysisView and SingleItemView (replaced by NewListingView) 2026-04-16 02:06:33 +01:00
48be042aa2 feat: create BulkImportWindow dialog, wire File > Bulk Import 2026-04-16 02:04:05 +01:00
bb5cd09ce2 feat: rewrite MainWindow to 2-tab layout with File menu 2026-04-16 01:58:52 +01:00
987c778ae2 feat: add State C success panel, expand SavedListingsService.Save with draft fields 2026-04-16 01:52:31 +01:00
bad466be1f feat: add State B review/edit panel to NewListingView 2026-04-16 01:45:32 +01:00
22 changed files with 1415 additions and 3022 deletions

View File

@@ -1,4 +1,4 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using Microsoft.Extensions.Configuration;

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
@@ -6,6 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<AssemblyName>EbayListingTool</AssemblyName>
<ApplicationIcon>app_icon.ico</ApplicationIcon>
<RootNamespace>EbayListingTool</RootNamespace>
</PropertyGroup>
@@ -28,6 +29,7 @@
</ItemGroup>
<ItemGroup>
<Resource Include="app_icon.ico" />
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

View File

@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace EbayListingTool.Models;
@@ -150,12 +150,12 @@ public class ListingDraft : INotifyPropertyChanged
public string ConditionId => Condition switch
{
ItemCondition.New => "1000",
ItemCondition.OpenBox => "1500",
ItemCondition.Refurbished => "2500",
ItemCondition.Used => "3000",
ItemCondition.ForPartsOrNotWorking => "7000",
_ => "3000"
ItemCondition.New => "NEW",
ItemCondition.OpenBox => "LIKE_NEW",
ItemCondition.Refurbished => "SELLER_REFURBISHED",
ItemCondition.Used => "USED_VERY_GOOD",
ItemCondition.ForPartsOrNotWorking => "FOR_PARTS_OR_NOT_WORKING",
_ => "USED_VERY_GOOD"
};
public event PropertyChangedEventHandler? PropertyChanged;

View File

@@ -1,4 +1,4 @@
namespace EbayListingTool.Models;
namespace EbayListingTool.Models;
public class PhotoAnalysisResult
{
@@ -18,6 +18,6 @@ public class PhotoAnalysisResult
public string PriceRangeDisplay =>
PriceMin > 0 && PriceMax > 0
? $"£{PriceMin:F2} £{PriceMax:F2} (suggested £{PriceSuggested:F2})"
: PriceSuggested > 0 ? $"£{PriceSuggested:F2}" : "";
? $"\u00A3{PriceMin:F2} \u00A3{PriceMax:F2} (suggested \u00A3{PriceSuggested:F2})"
: PriceSuggested > 0 ? $"\u00A3{PriceSuggested:F2}" : "";
}

View File

@@ -1,4 +1,4 @@
namespace EbayListingTool.Models;
namespace EbayListingTool.Models;
public class SavedListing
{
@@ -28,7 +28,7 @@ public class SavedListing
public string FirstPhotoPath => PhotoPaths.Count > 0 ? PhotoPaths[0] : "";
public string PriceDisplay => Price > 0 ? $"£{Price:F2}" : "—";
public string PriceDisplay => Price > 0 ? $"\u00A3{Price:F2}" : "—";
public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm");

View File

@@ -1,4 +1,4 @@
using System.Net.Http;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using EbayListingTool.Models;
@@ -65,7 +65,7 @@ public class AiAssistantService
string priceContext = "";
if (soldPrices != null && soldPrices.Any())
{
var prices = soldPrices.Select(p => $"£{p:F2}");
var prices = soldPrices.Select(p => $"\u00A3{p:F2}");
priceContext = $"\nRecent eBay UK sold prices for similar items: {string.Join(", ", prices)}";
}
@@ -143,7 +143,7 @@ public class AiAssistantService
RefineWithCorrectionsAsync(string title, string description, decimal price, string corrections)
{
var priceContext = price > 0
? $"Current price: £{price:F2}\n\n"
? $"Current price: \u00A3{price:F2}\n\n"
: "";
var prompt =
@@ -246,7 +246,7 @@ public class AiAssistantService
" \"confidence_notes\": \"one sentence explaining confidence level, e.g. brand clearly visible on label\"\n" +
"}\n\n" +
"For prices: research realistic eBay UK sold prices in your knowledge. " +
"price_suggested should be a good Buy It Now price. Use GBP numbers only (no £ symbol).";
"price_suggested should be a good Buy It Now price. Use GBP numbers only (no \u00A3 symbol).";
var json = await CallWithVisionAsync(dataUrls, prompt);

View File

@@ -16,11 +16,19 @@ public class EbayListingService
private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs
private static readonly HttpClient _photoHttp = new(); // Trading API (photo upload)
private static readonly JsonSerializerSettings _jsonSettings = new()
{ NullValueHandling = NullValueHandling.Ignore };
private static readonly string _logPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EbayListingTool", "crash_log.txt");
// Per-session cache of eBay account IDs — fetched once, reused for every listing
private string? _fulfillmentPolicyId;
private string? _paymentPolicyId;
private string? _returnPolicyId;
private string? _merchantLocationKey;
private bool _triedPolicySetup;
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
{
@@ -35,19 +43,17 @@ public class EbayListingService
_paymentPolicyId = null;
_returnPolicyId = null;
_merchantLocationKey = null;
_triedPolicySetup = false;
}
public async Task<string> PostListingAsync(ListingDraft draft)
{
var token = await _auth.GetValidAccessTokenAsync();
// Resolve business policies and merchant location before touching inventory/offers
await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
// 1. Upload photos and get eBay-hosted 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)
@@ -57,13 +63,8 @@ public class EbayListingService
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;
@@ -75,11 +76,6 @@ public class EbayListingService
// ---- Setup: policies + location ----
/// <summary>
/// Fetches fulfillment, payment and return policy IDs from the seller's eBay account,
/// and ensures at least one merchant location exists (creating "home" from the seller's
/// postcode if needed). Results are cached for the session.
/// </summary>
private async Task EnsurePoliciesAndLocationAsync(string token, string postcode)
{
var baseUrl = _auth.BaseUrl;
@@ -92,8 +88,17 @@ public class EbayListingService
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
{
if (!_triedPolicySetup && json.Contains("20403"))
{
_triedPolicySetup = true;
await SetupDefaultBusinessPoliciesAsync(token);
await EnsurePoliciesAndLocationAsync(token, postcode);
return;
}
throw new HttpRequestException(
$"Could not fetch fulfillment policies ({(int)res.StatusCode}): {json}");
}
var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray;
_fulfillmentPolicyId = arr?.Count > 0
@@ -101,9 +106,18 @@ public class EbayListingService
: null;
if (_fulfillmentPolicyId == null)
{
if (!_triedPolicySetup)
{
_triedPolicySetup = true;
await SetupDefaultBusinessPoliciesAsync(token);
await EnsurePoliciesAndLocationAsync(token, postcode);
return;
}
throw new InvalidOperationException(
"No fulfillment policy found on your eBay account.\n\n" +
"Please set one up in My eBay Account Business policies, then try again.");
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again.");
}
}
if (_paymentPolicyId == null)
@@ -125,7 +139,7 @@ public class EbayListingService
if (_paymentPolicyId == null)
throw new InvalidOperationException(
"No payment policy found on your eBay account.\n\n" +
"Please set one up in My eBay Account Business policies, then try again.");
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again.");
}
if (_returnPolicyId == null)
@@ -147,7 +161,7 @@ public class EbayListingService
if (_returnPolicyId == null)
throw new InvalidOperationException(
"No return policy found on your eBay account.\n\n" +
"Please set one up in My eBay Account Business policies, then try again.");
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again.");
}
if (_merchantLocationKey == null)
@@ -165,7 +179,6 @@ public class EbayListingService
: null;
}
// No existing locations — create one from the seller's postcode
if (_merchantLocationKey == null)
{
await CreateMerchantLocationAsync(token, postcode);
@@ -174,10 +187,92 @@ public class EbayListingService
}
}
private static void LogSetup(string msg)
{
Directory.CreateDirectory(Path.GetDirectoryName(_logPath)!);
File.AppendAllText(_logPath, $"{DateTime.Now:HH:mm:ss} [Setup] {msg}{Environment.NewLine}");
}
private async Task SetupDefaultBusinessPoliciesAsync(string token)
{
try
{
var optInBody = JsonConvert.SerializeObject(new { programType = "SELLING_POLICY_MANAGEMENT" });
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/account/v1/program/opt_in", token);
req.Content = new StringContent(optInBody, Encoding.UTF8, "application/json");
var res = await _http.SendAsync(req);
var body = await res.Content.ReadAsStringAsync();
LogSetup($"opt_in \u2192 {(int)res.StatusCode} {body}");
}
catch (Exception ex) { LogSetup($"opt_in exception: {ex.Message}"); }
await Task.WhenAll(
TryCreatePolicyAsync(token, "fulfillment_policy", new
{
name = "Standard UK Shipping",
marketplaceId = "EBAY_GB",
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
handlingTime = new { value = 1, unit = "DAY" },
shippingOptions = new[]
{
new
{
optionType = "DOMESTIC",
costType = "FLAT_RATE",
shippingServices = new[]
{
new
{
sortOrder = 1,
shippingCarrierCode = "ROYALMAIL",
shippingServiceCode = "UK_OtherCourier",
shippingCost = new { value = "2.85", currency = "GBP" },
additionalShippingCost = new { value = "0.00", currency = "GBP" }
}
}
}
}
}),
TryCreatePolicyAsync(token, "payment_policy", new
{
name = "Managed Payments",
marketplaceId = "EBAY_GB",
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
paymentMethods = Array.Empty<object>()
}),
TryCreatePolicyAsync(token, "return_policy", new
{
name = "Standard Returns",
marketplaceId = "EBAY_GB",
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
returnsAccepted = true,
returnPeriod = new { value = 30, unit = "DAY" },
refundMethod = "MONEY_BACK",
returnShippingCostPayer = "BUYER"
})
);
}
private async Task TryCreatePolicyAsync(string token, string policyType, object body)
{
try
{
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/account/v1/{policyType}", token);
req.Content = new StringContent(
JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
var res = await _http.SendAsync(req);
var respBody = await res.Content.ReadAsStringAsync();
LogSetup($"{policyType} create \u2192 {(int)res.StatusCode} {respBody}");
}
catch (Exception ex) { LogSetup($"{policyType} exception: {ex.Message}"); }
}
private async Task CreateMerchantLocationAsync(string token, string postcode)
{
if (string.IsNullOrWhiteSpace(postcode))
postcode = "N/A"; // eBay allows this when postcode is genuinely unknown
postcode = "N/A";
var body = new
{
@@ -214,9 +309,7 @@ public class EbayListingService
shipToLocationAvailability = new { quantity = draft.Quantity }
},
condition = draft.ConditionId,
conditionDescription = draft.Condition == ItemCondition.Used ? "Used see photos" : null,
description = draft.Description,
title = draft.Title,
conditionDescription = draft.Condition == ItemCondition.Used ? "Used \u2014 see photos" : null,
product = new
{
title = draft.Title,
@@ -226,14 +319,9 @@ public class EbayListingService
}
};
var json = JsonConvert.SerializeObject(inventoryItem, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
});
var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
using var req = MakeRequest(HttpMethod.Put, url, token);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Content = new StringContent(JsonConvert.SerializeObject(inventoryItem, _jsonSettings), Encoding.UTF8, "application/json");
req.Content.Headers.Add("Content-Language", "en-GB");
var res = await _http.SendAsync(req);
@@ -262,22 +350,14 @@ public class EbayListingService
paymentPolicyId = _paymentPolicyId,
returnPolicyId = _returnPolicyId
},
pricingSummary = new
{
price = new { value = draft.Price.ToString("F2"), currency = "GBP" }
},
pricingSummary = new { price = new { value = draft.Price.ToString("F2"), currency = "GBP" } },
merchantLocationKey = _merchantLocationKey,
tax = new { vatPercentage = 0, applyTax = false }
};
var json = JsonConvert.SerializeObject(offer, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
});
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/inventory/v1/offer", token);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Content = new StringContent(JsonConvert.SerializeObject(offer, _jsonSettings), Encoding.UTF8, "application/json");
var res = await _http.SendAsync(req);
var responseJson = await res.Content.ReadAsStringAsync();
@@ -311,29 +391,22 @@ public class EbayListingService
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
{
var urls = new List<string>();
if (photoPaths.Count == 0) return urls;
if (photoPaths.Count == 0) return [];
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))
var semaphore = new SemaphoreSlim(4);
var tasks = photoPaths.Take(12).Select(async path =>
{
if (!File.Exists(path)) continue;
try
{
var url = await UploadSinglePhotoAsync(path, tradingBase, token);
if (!string.IsNullOrEmpty(url))
urls.Add(url);
}
catch
{
// Skip failed photos; don't abort the whole listing
}
}
await semaphore.WaitAsync();
try { return await UploadSinglePhotoAsync(path, tradingBase, token); }
catch { return null; }
finally { semaphore.Release(); }
});
return urls;
return [.. (await Task.WhenAll(tasks)).Where(u => !string.IsNullOrEmpty(u))];
}
private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
@@ -352,7 +425,6 @@ public class EbayListingService
</UploadSiteHostedPicturesRequest>
""";
// Use HttpRequestMessage with _photoHttp so we don't create a new socket per photo
using var content = new MultipartFormDataContent();
content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
var imageContent = new ByteArrayContent(fileBytes);
@@ -376,7 +448,6 @@ public class EbayListingService
// ---- Helpers ----
/// <summary>Creates a pre-authorised request targeting the eBay REST APIs.</summary>
private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
{
var req = new HttpRequestMessage(method, url);

View File

@@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using EbayListingTool.Models;
namespace EbayListingTool.Services;
@@ -39,7 +39,7 @@ public class PriceLookupService
return new PriceSuggestion(
result.Suggested,
"ebay",
$"eBay suggests £{result.Suggested:F2} (from {result.Count} listings)");
$"eBay suggests \u00A3{result.Suggested:F2} (from {result.Count} listings)");
}
catch { /* eBay unavailable — fall through */ }
@@ -58,7 +58,7 @@ public class PriceLookupService
return new PriceSuggestion(
avg,
"history",
$"Your avg for {listing.Category}: £{avg:F2} ({sameCat.Count} listings)");
$"Your avg for {listing.Category}: \u00A3{avg:F2} ({sameCat.Count} listings)");
}
// 3. AI estimate
@@ -73,7 +73,7 @@ public class PriceLookupService
out var price)
&& price > 0)
{
return new PriceSuggestion(price, "ai", $"AI estimate: £{price:F2}");
return new PriceSuggestion(price, "ai", $"AI estimate: \u00A3{price:F2}");
}
}
catch { /* AI unavailable */ }

View File

@@ -1,4 +1,4 @@
using EbayListingTool.Models;
using EbayListingTool.Models;
using Newtonsoft.Json;
namespace EbayListingTool.Services;
@@ -35,7 +35,11 @@ public class SavedListingsService
public (SavedListing Listing, int SkippedPhotos) Save(
string title, string description, decimal price,
string category, string conditionNotes,
IEnumerable<string> sourcePaths)
IEnumerable<string> sourcePaths,
string categoryId = "",
ItemCondition condition = ItemCondition.Used,
ListingFormat format = ListingFormat.FixedPrice,
string postcode = "")
{
var safeName = MakeSafeFilename(title);
var exportDir = UniqueDir(Path.Combine(ExportsDir, safeName));
@@ -68,6 +72,10 @@ public class SavedListingsService
Description = description,
Price = price,
Category = category,
CategoryId = categoryId,
Condition = condition,
Format = format,
Postcode = postcode,
ConditionNotes = conditionNotes,
ExportFolder = exportDir,
PhotoPaths = photoPaths
@@ -188,7 +196,7 @@ public class SavedListingsService
var sb = new System.Text.StringBuilder();
sb.AppendLine($"Title: {title}");
sb.AppendLine($"Category: {category}");
sb.AppendLine($"Price: £{price:F2}");
sb.AppendLine($"Price: \u00A3{price:F2}");
if (!string.IsNullOrWhiteSpace(conditionNotes))
sb.AppendLine($"Condition: {conditionNotes}");
sb.AppendLine();

View File

@@ -0,0 +1,11 @@
<mah:MetroWindow x:Class="EbayListingTool.Views.BulkImportWindow"
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:local="clr-namespace:EbayListingTool.Views"
Title="Bulk Import - eBay Listing Tool"
Height="700" Width="1000"
MinHeight="500" MinWidth="700"
WindowStartupLocation="CenterOwner">
<local:BulkImportView x:Name="BulkView" />
</mah:MetroWindow>

View File

@@ -0,0 +1,18 @@
using EbayListingTool.Services;
using MahApps.Metro.Controls;
namespace EbayListingTool.Views;
public partial class BulkImportWindow : MetroWindow
{
public BulkImportWindow(
EbayListingService listingService,
EbayCategoryService categoryService,
AiAssistantService aiService,
BulkImportService bulkService,
EbayAuthService auth)
{
InitializeComponent();
BulkView.Initialise(listingService, categoryService, aiService, bulkService, auth);
}
}

View File

@@ -1,13 +1,14 @@
<mah:MetroWindow x:Class="EbayListingTool.Views.MainWindow"
<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"
Title="eBay Listing Tool - UK"
Height="820" Width="1180"
MinHeight="600" MinWidth="900"
WindowStartupLocation="CenterScreen"
Icon="/EbayListingTool;component/app_icon.ico"
GlowBrush="{DynamicResource MahApps.Brushes.Accent}">
<mah:MetroWindow.Resources>
@@ -59,105 +60,62 @@
</EventTrigger>
</Style.Triggers>
</Style>
<!-- Shared style for tab header icon -->
<Style x:Key="TabHeaderIcon" TargetType="iconPacks:PackIconMaterial">
<Setter Property="Width" Value="15"/>
<Setter Property="Height" Value="15"/>
<Setter Property="Margin" Value="0,0,7,0"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</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="LinkVariantOff" 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="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TabControl x:Name="MainTabs" Grid.Row="0"
<!-- Menu bar -->
<Menu Grid.Row="0"
Background="{DynamicResource MahApps.Brushes.Gray9}"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}">
<MenuItem Header="_File">
<MenuItem x:Name="BulkImportMenuItem" Header="Bulk Import..."
Click="BulkImport_Click">
<MenuItem.Icon>
<iconPacks:PackIconMaterial Kind="TableMultiple" Width="14" Height="14"/>
</MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="E_xit" Click="Exit_Click"/>
</MenuItem>
</Menu>
<!-- 2 tabs -->
<TabControl x:Name="MainTabs" Grid.Row="1"
Style="{DynamicResource MahApps.Styles.TabControl.Animated}">
<!-- ① Photo Analysis — always available, no eBay login needed -->
<TabItem Style="{StaticResource AppTabItem}">
<TabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Camera" Width="15" Height="15"
Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Photo Analyser" VerticalAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<!-- Tab content: welcome banner + actual view stacked -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Welcome banner — only shown when no photo loaded yet (PhotoView sets Visibility via x:Name) -->
<Border x:Name="WelcomeBanner" Grid.Row="0"
Background="{DynamicResource MahApps.Brushes.Accent}"
Padding="14,7" Visibility="Visible">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="Camera" Width="14" Height="14"
Margin="0,0,8,0" VerticalAlignment="Center"
Foreground="White"/>
<TextBlock Text="Drop a photo to identify any item and get an instant eBay price"
Foreground="White" FontSize="12" FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<local:PhotoAnalysisView x:Name="PhotoView" Grid.Row="1"/>
</Grid>
</TabItem>
<!-- ② New Listing — requires eBay connection -->
<!-- New Listing tab -->
<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"/>
<iconPacks:PackIconMaterial Kind="CameraPlus"
Style="{StaticResource TabHeaderIcon}"/>
<TextBlock Text="New Listing" VerticalAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<Grid>
<local:SingleItemView x:Name="SingleView"/>
<!-- Overlay shown when not connected -->
<local:NewListingView x:Name="NewListingView"/>
<!-- Overlay when not connected to eBay -->
<Border x:Name="NewListingOverlay" Visibility="Visible"
Background="{DynamicResource MahApps.Brushes.ThemeBackground}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
MaxWidth="340">
<!-- eBay logo circle -->
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="340">
<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"/>
@@ -167,20 +125,21 @@
<iconPacks:PackIconMaterial Kind="CartOutline" Width="32" Height="32"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
VerticalAlignment="Center"
/>
</Border>
<TextBlock Text="Connect to eBay"
FontSize="20" FontWeight="Bold"
<TextBlock Text="Connect to eBay" FontSize="20" FontWeight="Bold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.ThemeForeground}"
Margin="0,0,0,8"/>
<TextBlock Text="Sign in with your eBay account to start posting listings and managing your inventory."
<TextBlock Text="Sign in with your eBay account to identify items, get prices, and post listings."
FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,24"/>
<Button Click="ConnectBtn_Click"
<Button x:Name="ConnectBtn" Click="ConnectBtn_Click"
Style="{StaticResource LockConnectButton}"
HorizontalAlignment="Center">
HorizontalAlignment="Center"
AutomationProperties.Name="Connect to eBay account">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14"
Margin="0,0,7,0" VerticalAlignment="Center"/>
@@ -192,73 +151,21 @@
</Grid>
</TabItem>
<!-- ③ Saved Listings — always available -->
<TabItem x:Name="SavedTab" Style="{StaticResource AppTabItem}">
<!-- Drafts tab -->
<TabItem x:Name="DraftsTab" 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"/>
<iconPacks:PackIconMaterial Kind="BookmarkMultiple"
Style="{StaticResource TabHeaderIcon}"/>
<TextBlock Text="Drafts" 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"
Background="{DynamicResource MahApps.Brushes.ThemeBackground}">
<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.ThemeForeground}"
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.Gray5}"
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"
<Border Grid.Row="2"
Background="{DynamicResource MahApps.Brushes.Gray9}"
BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}">
@@ -271,7 +178,8 @@
<iconPacks:PackIconMaterial Kind="AlertCircleOutline"
Width="12" Height="12" Margin="0,0,5,0"
VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
/>
<TextBlock x:Name="StatusBar" Text="Ready" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"/>
@@ -282,6 +190,14 @@
<TextBlock x:Name="StatusBarEbay" Text="eBay: disconnected"
FontSize="11" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
<!-- Disconnect button shown when connected -->
<Button x:Name="DisconnectBtn" Click="DisconnectBtn_Click"
Visibility="Collapsed"
Style="{StaticResource MahApps.Styles.Button.Square}"
Padding="6,2" Margin="8,0,0,0" FontSize="10"
AutomationProperties.Name="Disconnect from eBay">
<TextBlock Text="Disconnect"/>
</Button>
</StackPanel>
</Grid>
</Border>

View File

@@ -31,18 +31,13 @@ public partial class MainWindow : MetroWindow
_priceService = new EbayPriceResearchService(_auth);
_priceLookupService = new PriceLookupService(_priceService, _savedService, _aiService);
// Photo Analysis tab — no eBay needed
PhotoView.Initialise(_aiService, _savedService, _priceService);
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
var defaultPostcode = config["Ebay:DefaultPostcode"] ?? "";
// Saved Listings tab
SavedView.Initialise(_savedService, _priceLookupService);
NewListingView.Initialise(_listingService, _categoryService, _aiService, _auth,
_savedService, defaultPostcode);
// New Listing + Bulk tabs
SingleView.Initialise(_listingService, _categoryService, _aiService, _auth);
BulkView.Initialise(_listingService, _categoryService, _aiService, _bulkService, _auth);
SavedView.Initialise(_savedService, _priceLookupService, _listingService, _auth);
// Try to restore saved eBay session
_auth.TryLoadSavedToken();
UpdateConnectionState();
}
@@ -52,7 +47,7 @@ public partial class MainWindow : MetroWindow
private async void ConnectBtn_Click(object sender, RoutedEventArgs e)
{
ConnectBtn.IsEnabled = false;
SetStatus("Connecting to eBay");
SetStatus("Connecting to eBay...");
try
{
var username = await _auth.LoginAsync();
@@ -68,14 +63,14 @@ public partial class MainWindow : MetroWindow
finally
{
ConnectBtn.IsEnabled = true;
UpdateConnectionState(); // always sync UI to actual auth state
UpdateConnectionState();
}
}
private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
{
_auth.Disconnect();
_listingService.ClearCache(); // clear cached policy/location IDs for next login
_listingService.ClearCache();
UpdateConnectionState();
SetStatus("Disconnected from eBay.");
}
@@ -83,50 +78,48 @@ public partial class MainWindow : MetroWindow
private void UpdateConnectionState()
{
var connected = _auth.IsConnected;
// Per-tab overlays (Photo Analysis tab has no overlay)
NewListingOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
BulkOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
ConnectBtn.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
DisconnectBtn.Visibility = connected ? Visibility.Visible : Visibility.Collapsed;
if (connected)
{
StatusDot.Fill = new SolidColorBrush(Colors.LimeGreen);
StatusLabel.Text = $"eBay: {_auth.ConnectedUsername}";
StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen);
StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}";
StatusBarEbay.Foreground = new SolidColorBrush(Colors.LimeGreen);
DisconnectBtn.Visibility = Visibility.Visible;
}
else
{
StatusDot.Fill = new SolidColorBrush(Colors.Gray);
StatusLabel.Text = "eBay: not connected";
StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
StatusBarEbay.Text = "eBay: disconnected";
StatusBarEbay.Foreground = (Brush)FindResource("MahApps.Brushes.Gray5");
DisconnectBtn.Visibility = Visibility.Collapsed;
}
}
// ---- Photo Analysis → New Listing handoff ----
// ---- File menu ----
private void OnUseDetailsRequested(PhotoAnalysisResult result, IReadOnlyList<string> photoPaths, decimal price)
private void BulkImport_Click(object sender, RoutedEventArgs e)
{
SingleView.PopulateFromAnalysis(result, photoPaths, price); // Q1: forward all photos
}
public void SwitchToNewListingTab()
if (!_auth.IsConnected)
{
MainTabs.SelectedItem = NewListingTab;
MessageBox.Show("Please connect to eBay before using Bulk Import.",
"Not Connected", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var win = new BulkImportWindow(_listingService, _categoryService, _aiService, _bulkService, _auth);
win.Owner = this;
win.ShowDialog();
}
public void RefreshSavedListings()
{
SavedView.RefreshList();
}
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
// ---- Helpers ----
// ---- Public interface for child views ----
public void SetStatus(string message) => StatusBar.Text = message;
public void SwitchToNewListingTab() => MainTabs.SelectedItem = NewListingTab;
public void RefreshDrafts() => SavedView.RefreshList();
public void RefreshSavedListings() => RefreshDrafts(); // backwards compat for NewListingView
}

View File

@@ -1,17 +1,67 @@
<UserControl x:Class="EbayListingTool.Views.NewListingView"
<UserControl x:Class="EbayListingTool.Views.NewListingView"
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>
<!-- Shared style for AI action buttons (Title AI, Desc AI, Price Research) -->
<Style x:Key="AiActionButton" TargetType="Button"
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
<Setter Property="Padding" Value="6,2"/>
</Style>
<!-- Shared style for field labels -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray3}"/>
</Style>
<!-- Shared style for section headers (PHOTOS, LISTING DETAILS) -->
<Style x:Key="SectionHeader" TargetType="TextBlock">
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray5}"/>
</Style>
<!-- Shared style for subtitle/hint text -->
<Style x:Key="HintText" TargetType="TextBlock">
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray5}"/>
</Style>
<!-- Shared style for character count labels -->
<Style x:Key="CharCountLabel" TargetType="TextBlock">
<Setter Property="FontSize" Value="12"/>
<Setter Property="Margin" Value="6,0,0,0"/>
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray5}"/>
</Style>
<!-- Shared style for progress track background -->
<Style x:Key="ProgressTrack" TargetType="Border">
<Setter Property="Height" Value="3"/>
<Setter Property="CornerRadius" Value="1.5"/>
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
</Style>
<!-- Shared style for progress track fill -->
<Style x:Key="ProgressFill" TargetType="Border">
<Setter Property="Height" Value="3"/>
<Setter Property="CornerRadius" Value="1.5"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="Width" Value="0"/>
</Style>
</UserControl.Resources>
<!-- Root grid hosts all three states; Visibility toggled in code-behind -->
<Grid>
<!-- ══════════════════════════════════════ STATE A: Drop Zone -->
<!-- STATE A: Drop Zone -->
<Grid x:Name="StateA" Visibility="Visible">
<DockPanel LastChildFill="True">
<!-- Loading panel shown while AI runs -->
<!-- Loading panel - shown while AI runs -->
<Border x:Name="LoadingPanel" DockPanel.Dock="Top"
Visibility="Collapsed"
Margin="60,30,60,0" Padding="30,40"
@@ -19,15 +69,16 @@
CornerRadius="10">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<mah:ProgressRing Width="36" Height="36"
IsTabStop="False"
HorizontalAlignment="Center" Margin="0,0,0,16"/>
<TextBlock x:Name="LoadingStepText"
Text="Examining the photo"
Text="Examining the photo."
FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
<TextBlock Text="This usually takes 1020 seconds"
FontSize="11" HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
<TextBlock Text="This usually takes 10-20 seconds"
Style="{StaticResource HintText}"
HorizontalAlignment="Center"
Margin="0,6,0,0"/>
</StackPanel>
</Border>
@@ -36,34 +87,39 @@
<Border x:Name="DropZoneBorder" DockPanel.Dock="Top"
Margin="60,30,60,0"
AllowDrop="True"
Focusable="True"
MouseLeftButtonUp="DropZone_Click"
DragOver="DropZone_DragOver"
DragEnter="DropZone_DragEnter"
DragLeave="DropZone_DragLeave"
Drop="DropZone_Drop"
Cursor="Hand"
MinHeight="180">
<Grid>
MinHeight="180"
AutomationProperties.Name="Photo drop zone - drop photos here or click to browse">
<Grid Background="Transparent">
<!-- Dashed border via Rectangle -->
<Rectangle x:Name="DropBorderRect"
StrokeThickness="2"
StrokeDashArray="6,4"
RadiusX="10" RadiusY="10"
Stroke="{DynamicResource MahApps.Brushes.Gray6}"/>
Stroke="{DynamicResource MahApps.Brushes.Gray6}"
/>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
Margin="0,40">
Margin="0,40"
IsHitTestVisible="False">
<iconPacks:PackIconMaterial Kind="CameraOutline"
Width="52" Height="52"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,16"/>
Margin="0,0,0,16"
IsTabStop="False"/>
<TextBlock Text="Drop photos here"
FontSize="18" FontWeight="SemiBold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
<TextBlock Text="or click to browse up to 12 photos"
FontSize="12" HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
<TextBlock Text="or click to browse - up to 12 photos"
Style="{StaticResource HintText}"
HorizontalAlignment="Center"
Margin="0,6,0,0"/>
</StackPanel>
</Grid>
@@ -73,6 +129,7 @@
<ScrollViewer x:Name="ThumbScroller" DockPanel.Dock="Top"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Focusable="False"
Margin="60,12,60,0" Visibility="Collapsed">
<StackPanel x:Name="ThumbStrip" Orientation="Horizontal"/>
</ScrollViewer>
@@ -83,14 +140,16 @@
Click="Analyse_Click"
IsEnabled="False"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
Padding="28,12" FontSize="14" FontWeight="SemiBold">
Padding="28,12" FontSize="14" FontWeight="SemiBold"
AutomationProperties.Name="Identify and price item with AI">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="AnalyseIcon"
Kind="MagnifyScan" Width="18" Height="18"
Margin="0,0,8,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="AnalyseSpinner"
Width="18" Height="18" Margin="0,0,8,0"
Visibility="Collapsed"/>
Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock x:Name="AnalyseBtnText"
Text="Identify &amp; Price with AI"
VerticalAlignment="Center"/>
@@ -98,7 +157,7 @@
</Button>
<TextBlock x:Name="PhotoCountLabel"
HorizontalAlignment="Center" Margin="0,8,0,0"
FontSize="11" Visibility="Collapsed"
FontSize="13" Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
</StackPanel>
@@ -106,10 +165,374 @@
</DockPanel>
</Grid>
<!-- ══════════════════════════════════════ STATE B: Review & Edit (stub for now) -->
<Grid x:Name="StateB" Visibility="Collapsed"/>
<!-- STATE B: Review & Edit -->
<Grid x:Name="StateB" Visibility="Collapsed">
<DockPanel LastChildFill="True">
<!-- ══════════════════════════════════════ STATE C: Success (stub for now) -->
<Grid x:Name="StateC" Visibility="Collapsed"/>
<!-- Footer bar - pinned to bottom via DockPanel.Dock -->
<Border DockPanel.Dock="Bottom"
Background="{DynamicResource MahApps.Brushes.Gray9}"
BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
Padding="16,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" x:Name="StartOverBtn"
Click="StartOver_Click"
Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Cursor="Hand" VerticalAlignment="Center"
AutomationProperties.Name="Start over and discard edits">
<TextBlock FontSize="13">
<Run Text="&#8592; "/>
<Run Text="Start Over" TextDecorations="Underline"/>
</TextBlock>
</Button>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="SaveDraftBtn"
Click="SaveDraft_Click"
Style="{StaticResource MahApps.Styles.Button.Square}"
Padding="16,8" Margin="0,0,8,0"
AutomationProperties.Name="Save listing as draft">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentSaveOutline"
Width="14" Height="14" Margin="0,0,6,0"
VerticalAlignment="Center"/>
<TextBlock Text="Save as Draft" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button x:Name="PostBtn"
Click="Post_Click"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
Padding="16,8"
AutomationProperties.Name="Post listing to eBay">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="PostIcon"
Kind="CartArrowRight" Width="14" Height="14"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="PostSpinner"
Width="14" Height="14" Margin="0,0,6,0"
Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Two-column content area -->
<Grid Margin="16,12,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220" MinWidth="160"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- LEFT: Photos panel -->
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top"
Text="PHOTOS" Style="{StaticResource SectionHeader}"
Margin="0,0,0,8"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button x:Name="AddMorePhotosBtn" Click="AddMorePhotos_Click"
Style="{StaticResource MahApps.Styles.Button.Square}"
Padding="8,4" FontSize="13"
AutomationProperties.Name="Add more photos to listing">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Plus" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="Add more" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<TextBlock x:Name="BPhotoCount"
Margin="8,0,0,0" VerticalAlignment="Center"
FontSize="13"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto"
Focusable="False">
<WrapPanel x:Name="BPhotosPanel"/>
</ScrollViewer>
</DockPanel>
<!-- RIGHT: Listing fields -->
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto"
Focusable="False">
<StackPanel Margin="0,0,8,16" MaxWidth="600">
<TextBlock Text="LISTING DETAILS"
Style="{StaticResource SectionHeader}"
Margin="0,0,0,12"/>
<!-- Title -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TitleLabel" Text="Title"
Style="{StaticResource FieldLabel}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="AiTitleBtn" Click="AiTitle_Click"
Style="{StaticResource AiActionButton}"
ToolTip="Improve title with AI"
AutomationProperties.Name="Improve title with AI">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="TitleAiIcon"
Kind="AutoFix" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="TitleSpinner" Width="12" Height="12"
Margin="0,0,4,0" Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="AI" FontSize="12" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<TextBox x:Name="BTitleBox" TextChanged="TitleBox_TextChanged"
MaxLength="80" Margin="0,0,0,2"
AutomationProperties.LabeledBy="{Binding ElementName=TitleLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Style="{StaticResource ProgressTrack}">
<Border x:Name="BTitleBar" Style="{StaticResource ProgressFill}"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Border>
<TextBlock x:Name="BTitleCount" Grid.Column="1"
Text="0 / 80" Style="{StaticResource CharCountLabel}"/>
</Grid>
<!-- Description -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="DescLabel" Text="Description"
Style="{StaticResource FieldLabel}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="AiDescBtn" Click="AiDesc_Click"
Style="{StaticResource AiActionButton}"
ToolTip="Write description with AI"
AutomationProperties.Name="Write description with AI">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="DescAiIcon"
Kind="AutoFix" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="DescSpinner" Width="12" Height="12"
Margin="0,0,4,0" Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="AI" FontSize="12" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<TextBox x:Name="BDescBox" TextChanged="DescBox_TextChanged"
AcceptsReturn="True" TextWrapping="Wrap"
Height="110" VerticalScrollBarVisibility="Auto"
Margin="0,0,0,2"
AutomationProperties.LabeledBy="{Binding ElementName=DescLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Style="{StaticResource ProgressTrack}">
<Border x:Name="BDescBar" Style="{StaticResource ProgressFill}"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Border>
<TextBlock x:Name="BDescCount" Grid.Column="1"
Text="0 / 2000" Style="{StaticResource CharCountLabel}"/>
</Grid>
<!-- Category -->
<TextBlock x:Name="CategoryLabel" Text="Category"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<Grid Margin="0,0,0,2">
<TextBox x:Name="BCategoryBox"
TextChanged="CategoryBox_TextChanged"
KeyDown="CategoryBox_KeyDown"
mah:TextBoxHelper.Watermark="Type to search categories."
AutomationProperties.LabeledBy="{Binding ElementName=CategoryLabel}"/>
<ListBox x:Name="BCategoryList"
Visibility="Collapsed"
SelectionChanged="CategoryList_SelectionChanged"
MaxHeight="160"
VerticalAlignment="Top"
Margin="0,32,0,0"
Panel.ZIndex="10"
Background="{DynamicResource MahApps.Brushes.Gray8}"
BorderBrush="{DynamicResource MahApps.Brushes.Gray6}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.CanContentScroll="True"
AutomationProperties.Name="Category suggestions"/>
</Grid>
<TextBlock x:Name="BCategoryIdLabel"
Text="(no category selected)"
FontSize="12" Margin="0,0,0,12"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<!-- Condition + Format -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock x:Name="ConditionLabel" Text="Condition"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<ComboBox x:Name="BConditionBox"
SelectionChanged="ConditionBox_SelectionChanged"
AutomationProperties.LabeledBy="{Binding ElementName=ConditionLabel}">
<ComboBoxItem Content="New" Tag="New"/>
<ComboBoxItem Content="Open Box" Tag="OpenBox"/>
<ComboBoxItem Content="Refurbished" Tag="Refurbished"/>
<ComboBoxItem Content="Used" Tag="Used" IsSelected="True"/>
<ComboBoxItem Content="For Parts" Tag="ForParts"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock x:Name="FormatLabel" Text="Format"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<ComboBox x:Name="BFormatBox"
AutomationProperties.LabeledBy="{Binding ElementName=FormatLabel}">
<ComboBoxItem Content="Fixed Price" IsSelected="True"/>
<ComboBoxItem Content="Auction"/>
</ComboBox>
</StackPanel>
</Grid>
<!-- Price -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="PriceLabel" Text="Price"
Style="{StaticResource FieldLabel}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="AiPriceBtn" Click="AiPrice_Click"
Style="{StaticResource AiActionButton}"
ToolTip="Research live eBay price"
AutomationProperties.Name="Research live eBay price">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="PriceAiIcon"
Kind="Magnify" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="PriceSpinner" Width="12" Height="12"
Margin="0,0,4,0" Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="Research" FontSize="12" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<mah:NumericUpDown x:Name="BPriceBox" ValueChanged="PriceBox_ValueChanged"
StringFormat="&#x00A3;{0:0.00}"
Minimum="0" Maximum="99999"
Interval="0.50"
Margin="0,0,0,4"
AutomationProperties.LabeledBy="{Binding ElementName=PriceLabel}"/>
<TextBlock x:Name="BPriceHint"
FontSize="12" Margin="0,0,0,4"
Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<TextBlock x:Name="BFeeLabel"
FontSize="12" Margin="0,0,0,12"
Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
AutomationProperties.Name="Estimated eBay listing fee"/>
<!-- Postage + Postcode -->
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock x:Name="PostageLabel" Text="Postage"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<ComboBox x:Name="BPostageBox" SelectionChanged="PostageBox_SelectionChanged"
AutomationProperties.LabeledBy="{Binding ElementName=PostageLabel}">
<ComboBoxItem Content="Royal Mail 1st Class" Tag="RoyalMailFirstClass"/>
<ComboBoxItem Content="Royal Mail 2nd Class" Tag="RoyalMailSecondClass" IsSelected="True"/>
<ComboBoxItem Content="Royal Mail Tracked 24" Tag="RoyalMailTracked24"/>
<ComboBoxItem Content="Royal Mail Tracked 48" Tag="RoyalMailTracked48"/>
<ComboBoxItem Content="Collection Only" Tag="CollectionOnly"/>
<ComboBoxItem Content="Free Postage" Tag="FreePostage"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock x:Name="PostcodeLabel" Text="From postcode"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<TextBox x:Name="BPostcodeBox"
AutomationProperties.LabeledBy="{Binding ElementName=PostcodeLabel}"/>
</StackPanel>
</Grid>
</StackPanel>
</ScrollViewer>
</Grid>
</DockPanel>
</Grid>
<!-- STATE C: Success -->
<Grid x:Name="StateC" Visibility="Collapsed">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="480">
<!-- Success banner -->
<Border Background="#1A4CAF50" BorderBrush="#4CAF50" BorderThickness="0,0,0,3"
CornerRadius="8" Padding="24,16" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="CheckCircleOutline" Width="24" Height="24"
Foreground="#4CAF50" VerticalAlignment="Center" Margin="0,0,12,0"
IsTabStop="False"/>
<TextBlock Text="Listed successfully!" FontSize="18" FontWeight="SemiBold"
Foreground="#4CAF50" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- URL -->
<TextBlock Text="Your listing is live at:" FontSize="12"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
HorizontalAlignment="Center" Margin="0,0,0,8"/>
<TextBlock x:Name="BSuccessUrl"
FontSize="13" TextDecorations="Underline"
Foreground="{DynamicResource MahApps.Brushes.Accent}"
HorizontalAlignment="Center" Cursor="Hand" TextWrapping="Wrap"
TextAlignment="Center" Margin="0,0,0,16"
MouseLeftButtonUp="SuccessUrl_Click"
AutomationProperties.Name="Listing URL - click to open"/>
<Button x:Name="CopyUrlBtn" Click="CopyUrl_Click"
Style="{StaticResource MahApps.Styles.Button.Square}"
HorizontalAlignment="Center" Padding="16,8" Margin="0,0,0,36"
AutomationProperties.Name="Copy listing URL to clipboard">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Copy URL" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- List Another -->
<Button Click="ListAnother_Click"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
HorizontalAlignment="Center" Padding="24,12" FontSize="14"
AutomationProperties.Name="List another item">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Plus" Width="16" Height="16"
Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="List Another Item" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Grid>
</UserControl>

View File

@@ -1,4 +1,4 @@
using System.Windows;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
@@ -23,7 +23,7 @@ public partial class NewListingView : UserControl
private readonly List<string> _photoPaths = new();
private const int MaxPhotos = 12;
// State B — draft being edited (stub, populated in Task 4)
// State B — draft being edited
private ListingDraft _draft = new();
private PhotoAnalysisResult? _lastAnalysis;
private bool _suppressCategoryLookup;
@@ -142,19 +142,19 @@ public partial class NewListingView : UserControl
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 80;
bmp.DecodePixelWidth = 120;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
var img = new Image
{
Source = bmp, Width = 60, Height = 60,
Source = bmp, Width = 96, Height = 96,
Stretch = System.Windows.Media.Stretch.UniformToFill,
Margin = new Thickness(3)
Margin = new Thickness(4)
};
img.Clip = new System.Windows.Media.RectangleGeometry(
new Rect(0, 0, 60, 60), 4, 4);
new Rect(0, 0, 96, 96), 6, 6);
ThumbStrip.Children.Add(img);
}
catch { /* skip bad files */ }
@@ -211,10 +211,428 @@ public partial class NewListingView : UserControl
}
}
// Stub for State B — implemented in Task 4
private Task PopulateStateBAsync(PhotoAnalysisResult result) => Task.CompletedTask;
// ---- State B: Populate from analysis ----
private async Task PopulateStateBAsync(PhotoAnalysisResult result)
{
_draft = new ListingDraft { Postcode = _defaultPostcode };
_draft.PhotoPaths = new List<string>(_photoPaths);
RebuildBPhotoThumbnails();
BTitleBox.Text = result.Title;
BDescBox.Text = result.Description;
BPriceBox.Value = (double)Math.Round(result.PriceSuggested, 2);
BPostcodeBox.Text = _defaultPostcode;
BConditionBox.SelectedIndex = 3; // Used
if (!string.IsNullOrWhiteSpace(result.CategoryKeyword))
await AutoFillCategoryAsync(result.CategoryKeyword);
if (result.PriceMin > 0 && result.PriceMax > 0)
{
BPriceHint.Text = $"AI estimate: \u00A3{result.PriceMin:F2} \u00A3{result.PriceMax:F2}";
BPriceHint.Visibility = Visibility.Visible;
}
}
// ---- Title ----
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
{
_draft.Title = BTitleBox.Text;
var len = BTitleBox.Text.Length;
BTitleCount.Text = $"{len} / 80";
var over = len > 75;
var trackBorder = BTitleBar.Parent as Border;
double trackWidth = trackBorder?.ActualWidth ?? 0;
if (trackWidth > 0) BTitleBar.Width = trackWidth * (len / 80.0);
BTitleBar.Background = over
? System.Windows.Media.Brushes.OrangeRed
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
BTitleCount.Foreground = over
? System.Windows.Media.Brushes.OrangeRed
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
}
private async void AiTitle_Click(object sender, RoutedEventArgs e)
{
if (_aiService == null) return;
SetTitleBusy(true);
try
{
var title = await _aiService.GenerateTitleAsync(BTitleBox.Text, GetSelectedCondition().ToString());
BTitleBox.Text = title.Trim().TrimEnd('.').Trim('"');
if (string.IsNullOrWhiteSpace(_draft.CategoryId))
await AutoFillCategoryAsync(BTitleBox.Text);
}
catch (Exception ex) { ShowError("AI Title", ex.Message); }
finally { SetTitleBusy(false); }
}
private void SetTitleBusy(bool busy)
{
AiTitleBtn.IsEnabled = !busy;
TitleSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
TitleAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
}
// ---- Description ----
private void DescBox_TextChanged(object sender, TextChangedEventArgs e)
{
_draft.Description = BDescBox.Text;
var len = BDescBox.Text.Length;
const int softCap = 2000;
BDescCount.Text = $"{len} / {softCap}";
var over = len > softCap;
var trackBorder = BDescBar.Parent as Border;
double trackWidth = trackBorder?.ActualWidth ?? 0;
if (trackWidth > 0) BDescBar.Width = Math.Min(trackWidth, trackWidth * (len / (double)softCap));
BDescBar.Background = over
? System.Windows.Media.Brushes.OrangeRed
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xF5, 0x9E, 0x0B));
BDescCount.Foreground = over
? System.Windows.Media.Brushes.OrangeRed
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
}
private async void AiDesc_Click(object sender, RoutedEventArgs e)
{
if (_aiService == null) return;
SetDescBusy(true);
try
{
var desc = await _aiService.WriteDescriptionAsync(
BTitleBox.Text, GetSelectedCondition().ToString(), BDescBox.Text);
BDescBox.Text = desc;
}
catch (Exception ex) { ShowError("AI Description", ex.Message); }
finally { SetDescBusy(false); }
}
private void SetDescBusy(bool busy)
{
AiDescBtn.IsEnabled = !busy;
DescSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
DescAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
}
// ---- Category ----
private void CategoryBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (_suppressCategoryLookup) return;
_categoryCts?.Cancel();
_categoryCts?.Dispose();
_categoryCts = new System.Threading.CancellationTokenSource();
var cts = _categoryCts;
if (BCategoryBox.Text.Length < 3) { BCategoryList.Visibility = Visibility.Collapsed; return; }
_ = SearchCategoryAsync(BCategoryBox.Text, cts);
}
private async Task SearchCategoryAsync(string text, System.Threading.CancellationTokenSource cts)
{
try
{
await Task.Delay(350, cts.Token);
if (cts.IsCancellationRequested) return;
var suggestions = await _categoryService!.GetCategorySuggestionsAsync(text);
if (cts.IsCancellationRequested) return;
BCategoryList.ItemsSource = suggestions.Select(s => s.CategoryName).ToList();
BCategoryList.Tag = suggestions;
BCategoryList.Visibility = suggestions.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
catch (OperationCanceledException) { }
catch { }
}
private void CategoryBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) { BCategoryList.Visibility = Visibility.Collapsed; e.Handled = true; }
}
private void CategoryList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (BCategoryList.SelectedIndex < 0) return;
var suggestions = BCategoryList.Tag as List<CategorySuggestion>;
if (suggestions == null || BCategoryList.SelectedIndex >= suggestions.Count) return;
var cat = suggestions[BCategoryList.SelectedIndex];
_suppressCategoryLookup = true;
_draft.CategoryId = cat.CategoryId;
_draft.CategoryName = cat.CategoryName;
BCategoryBox.Text = cat.CategoryName;
BCategoryIdLabel.Text = $"ID: {cat.CategoryId}";
BCategoryList.Visibility = Visibility.Collapsed;
_suppressCategoryLookup = false;
}
private async Task AutoFillCategoryAsync(string keyword)
{
if (_categoryService == null || string.IsNullOrWhiteSpace(keyword)) return;
try
{
var suggestions = await _categoryService.GetCategorySuggestionsAsync(keyword);
if (suggestions.Count == 0) return;
var top = suggestions[0];
_suppressCategoryLookup = true;
_draft.CategoryId = top.CategoryId;
_draft.CategoryName = top.CategoryName;
BCategoryBox.Text = top.CategoryName;
BCategoryIdLabel.Text = $"ID: {top.CategoryId}";
_suppressCategoryLookup = false;
BCategoryList.ItemsSource = suggestions.Select(s => s.CategoryName).ToList();
BCategoryList.Tag = suggestions;
BCategoryList.Visibility = suggestions.Count > 1 ? Visibility.Visible : Visibility.Collapsed;
}
catch { }
}
// ---- Condition ----
private void ConditionBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
_draft.Condition = GetSelectedCondition();
}
private ItemCondition GetSelectedCondition()
{
var tag = (BConditionBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Used";
return tag switch
{
"New" => ItemCondition.New,
"OpenBox" => ItemCondition.OpenBox,
"Refurbished" => ItemCondition.Refurbished,
"ForParts" => ItemCondition.ForPartsOrNotWorking,
_ => ItemCondition.Used
};
}
// ---- Price ----
private async void AiPrice_Click(object sender, RoutedEventArgs e)
{
if (_aiService == null) return;
SetPriceBusy(true);
try
{
var result = await _aiService.SuggestPriceAsync(BTitleBox.Text, GetSelectedCondition().ToString());
var lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var priceLine = lines.FirstOrDefault(l => l.StartsWith("PRICE:", StringComparison.OrdinalIgnoreCase));
_suggestedPriceValue = priceLine?.Replace("PRICE:", "", StringComparison.OrdinalIgnoreCase).Trim() ?? "";
BPriceHint.Text = lines.FirstOrDefault() ?? result;
BPriceHint.Visibility = Visibility.Visible;
if (decimal.TryParse(_suggestedPriceValue, out var price))
BPriceBox.Value = (double)price;
}
catch (Exception ex) { ShowError("AI Price", ex.Message); }
finally { SetPriceBusy(false); }
}
private void PriceBox_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double?> e)
=> UpdateFeeEstimate();
private void PostageBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
=> UpdateFeeEstimate();
private static readonly Dictionary<string, decimal> PostageEstimates = new()
{
["RoyalMailFirstClass"] = 3.70m,
["RoyalMailSecondClass"] = 2.85m,
["RoyalMailTracked24"] = 4.35m,
["RoyalMailTracked48"] = 3.60m,
["CollectionOnly"] = 0m,
["FreePostage"] = 0m,
};
private void UpdateFeeEstimate()
{
if (BFeeLabel == null) return;
var price = (decimal)(BPriceBox?.Value ?? 0);
if (price <= 0) { BFeeLabel.Visibility = Visibility.Collapsed; return; }
var postageTag = (BPostageBox?.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
PostageEstimates.TryGetValue(postageTag, out var postageEst);
const decimal fvfRate = 0.128m;
const decimal minFee = 0.30m;
var fee = Math.Max(Math.Round((price + postageEst) * fvfRate, 2), minFee);
var postageNote = postageEst > 0 ? $" + est. \u00A3{postageEst:F2} postage" : "";
BFeeLabel.Text = $"Est. eBay fee: \u00A3{fee:F2} (12.8% of \u00A3{price:F2}{postageNote})";
BFeeLabel.Visibility = Visibility.Visible;
}
private void SetPriceBusy(bool busy)
{
AiPriceBtn.IsEnabled = !busy;
PriceSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
PriceAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
}
// ---- Photos (State B) ----
private void RebuildBPhotoThumbnails()
{
BPhotosPanel.Children.Clear();
for (int i = 0; i < _draft.PhotoPaths.Count; i++)
AddBPhotoThumbnail(_draft.PhotoPaths[i], i);
BPhotoCount.Text = $"{_draft.PhotoPaths.Count} / {MaxPhotos}";
BPhotoCount.Foreground = _draft.PhotoPaths.Count >= MaxPhotos
? System.Windows.Media.Brushes.OrangeRed
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
}
private void AddBPhotoThumbnail(string path, int index)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 160;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
var img = new Image
{
Width = 100, Height = 100,
Stretch = System.Windows.Media.Stretch.UniformToFill,
Source = bmp, ToolTip = System.IO.Path.GetFileName(path)
};
img.Clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 100, 100), 6, 6);
var removeBtn = new Button
{
Width = 18, Height = 18, Content = "\u2715",
FontSize = 11, FontWeight = FontWeights.Bold,
Cursor = Cursors.Hand, ToolTip = "Remove",
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 2, 2, 0), Padding = new Thickness(0),
Background = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromArgb(200, 30, 30, 30)),
Foreground = System.Windows.Media.Brushes.White,
BorderThickness = new Thickness(0), Opacity = 0
};
removeBtn.Click += (s, ev) =>
{
ev.Handled = true;
_draft.PhotoPaths.Remove(path);
RebuildBPhotoThumbnails();
};
Border? coverBadge = null;
if (index == 0)
{
coverBadge = new Border
{
CornerRadius = new CornerRadius(3),
Background = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromArgb(210, 60, 90, 200)),
Padding = new Thickness(3, 1, 3, 1),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(2, 2, 0, 0),
IsHitTestVisible = false,
Child = new TextBlock
{
Text = "Cover", FontSize = 8, FontWeight = FontWeights.SemiBold,
Foreground = System.Windows.Media.Brushes.White
}
};
}
var container = new Grid
{
Width = 100, Height = 100, Margin = new Thickness(4),
Cursor = Cursors.SizeAll, AllowDrop = true, Tag = path
};
container.Children.Add(img);
if (coverBadge != null) container.Children.Add(coverBadge);
container.Children.Add(removeBtn);
container.MouseEnter += (s, ev) => removeBtn.Opacity = 1;
container.MouseLeave += (s, ev) => removeBtn.Opacity = 0;
Point dragStart = default;
bool isDragging = false;
container.MouseLeftButtonDown += (s, ev) => dragStart = ev.GetPosition(null);
container.MouseMove += (s, ev) =>
{
if (ev.LeftButton != MouseButtonState.Pressed || isDragging) return;
var pos = ev.GetPosition(null);
if (Math.Abs(pos.X - dragStart.X) > SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(pos.Y - dragStart.Y) > SystemParameters.MinimumVerticalDragDistance)
{
isDragging = true;
DragDrop.DoDragDrop(container, path, DragDropEffects.Move);
isDragging = false;
}
};
container.DragOver += (s, ev) =>
{
if (ev.Data.GetDataPresent(typeof(string)) &&
(string)ev.Data.GetData(typeof(string)) != path)
{ ev.Effects = DragDropEffects.Move; container.Opacity = 0.45; }
else ev.Effects = DragDropEffects.None;
ev.Handled = true;
};
container.DragLeave += (s, ev) => container.Opacity = 1.0;
container.Drop += (s, ev) =>
{
container.Opacity = 1.0;
if (!ev.Data.GetDataPresent(typeof(string))) return;
var src = (string)ev.Data.GetData(typeof(string));
var tgt = (string)container.Tag;
if (src == tgt) return;
var si = _draft.PhotoPaths.IndexOf(src);
var ti = _draft.PhotoPaths.IndexOf(tgt);
if (si < 0 || ti < 0) return;
_draft.PhotoPaths.RemoveAt(si);
_draft.PhotoPaths.Insert(ti, src);
RebuildBPhotoThumbnails();
ev.Handled = true;
};
BPhotosPanel.Children.Add(container);
}
catch { }
}
private void AddMorePhotos_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Add more photos",
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*",
Multiselect = true
};
if (dlg.ShowDialog() == true)
{
foreach (var path in dlg.FileNames)
{
if (_draft.PhotoPaths.Count >= MaxPhotos) break;
if (!_draft.PhotoPaths.Contains(path)) _draft.PhotoPaths.Add(path);
}
RebuildBPhotoThumbnails();
}
}
// ---- Footer actions ----
private void StartOver_Click(object sender, RoutedEventArgs e)
{
var isDirty = !string.IsNullOrWhiteSpace(BTitleBox.Text) ||
!string.IsNullOrWhiteSpace(BDescBox.Text);
if (isDirty)
{
var result = MessageBox.Show("Start over? Any edits will be lost.",
"Start Over", MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (result != MessageBoxResult.OK) return;
}
ResetToStateA();
}
// Stub for ResetToStateA — implemented in Task 4
public void ResetToStateA()
{
_photoPaths.Clear();
@@ -222,9 +640,140 @@ public partial class NewListingView : UserControl
_lastAnalysis = null;
UpdateThumbStrip();
UpdateAnalyseButton();
if (BPhotosPanel != null) BPhotosPanel.Children.Clear();
if (BTitleBox != null) BTitleBox.Text = "";
if (BDescBox != null) BDescBox.Text = "";
if (BCategoryBox != null) { BCategoryBox.Text = ""; BCategoryList.Visibility = Visibility.Collapsed; }
if (BCategoryIdLabel != null) BCategoryIdLabel.Text = "(no category selected)";
if (BPriceBox != null) BPriceBox.Value = 0;
if (BPriceHint != null) BPriceHint.Visibility = Visibility.Collapsed;
if (BConditionBox != null) BConditionBox.SelectedIndex = 3;
if (BFormatBox != null) BFormatBox.SelectedIndex = 0;
if (BPostcodeBox != null) BPostcodeBox.Text = _defaultPostcode;
SetState(ListingState.DropZone);
}
private void SaveDraft_Click(object sender, RoutedEventArgs e)
{
if (_savedService == null) return;
if (!ValidateDraft()) return;
CollectDraftFromFields();
try
{
_savedService.Save(
_draft.Title, _draft.Description, _draft.Price,
_draft.CategoryName, "",
_draft.PhotoPaths,
_draft.CategoryId, _draft.Condition, _draft.Format,
BPostcodeBox.Text);
GetWindow()?.RefreshSavedListings();
GetWindow()?.SetStatus($"Draft saved: {_draft.Title}");
ResetToStateA();
}
catch (Exception ex) { ShowError("Save Failed", ex.Message); }
}
private async void Post_Click(object sender, RoutedEventArgs e)
{
if (_listingService == null) return;
if (!ValidateDraft()) return;
CollectDraftFromFields();
SetPostBusy(true);
try
{
var url = await _listingService.PostListingAsync(_draft);
_draft.EbayListingUrl = url;
// Persist a record of the posting
if (_savedService != null)
{
try
{
_savedService.Save(
_draft.Title, _draft.Description, _draft.Price,
_draft.CategoryName, $"Posted: {url}",
_draft.PhotoPaths,
_draft.CategoryId, _draft.Condition, _draft.Format,
_draft.Postcode);
GetWindow()?.RefreshSavedListings();
}
catch { /* non-critical — posting succeeded, history save is best-effort */ }
}
BSuccessUrl.Text = url;
SetState(ListingState.Success);
GetWindow()?.SetStatus($"Listed: {_draft.Title}");
}
catch (Exception ex)
{
// Log full stack trace to help diagnose crashes
try
{
var logPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EbayListingTool", "crash_log.txt");
var msg = $"{DateTime.Now:HH:mm:ss} [Post_Click] {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n";
if (ex.InnerException != null)
msg += $" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}\n";
System.IO.File.AppendAllText(logPath, msg + "\n");
}
catch { }
ShowError("Post Failed", ex.Message);
}
finally { SetPostBusy(false); }
}
private void CollectDraftFromFields()
{
_draft.Title = BTitleBox.Text.Trim();
_draft.Description = BDescBox.Text.Trim();
_draft.Price = (decimal)(BPriceBox.Value ?? 0);
_draft.Condition = GetSelectedCondition();
_draft.Format = BFormatBox.SelectedIndex == 0 ? ListingFormat.FixedPrice : ListingFormat.Auction;
_draft.Postcode = BPostcodeBox.Text;
_draft.Quantity = 1;
}
private bool ValidateDraft()
{
if (string.IsNullOrWhiteSpace(BTitleBox?.Text))
{ ShowError("Validation", "Please enter a title."); return false; }
if (BTitleBox.Text.Length > 80)
{ ShowError("Validation", "Title must be 80 characters or fewer."); return false; }
if (string.IsNullOrEmpty(_draft.CategoryId))
{ ShowError("Validation", "Please select a category."); return false; }
if ((BPriceBox?.Value ?? 0) <= 0)
{ ShowError("Validation", "Please enter a price greater than zero."); return false; }
return true;
}
private void SetPostBusy(bool busy)
{
PostBtn.IsEnabled = !busy;
PostSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
PostIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
IsEnabled = !busy;
}
private void ShowError(string title, string msg)
=> MessageBox.Show(msg, title, MessageBoxButton.OK, MessageBoxImage.Warning);
// ---- State C handlers ----
private void SuccessUrl_Click(object sender, MouseButtonEventArgs e)
{
var url = BSuccessUrl.Text;
if (!string.IsNullOrEmpty(url))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
{ UseShellExecute = true });
}
private void CopyUrl_Click(object sender, RoutedEventArgs e)
=> System.Windows.Clipboard.SetText(BSuccessUrl.Text);
private void ListAnother_Click(object sender, RoutedEventArgs e)
=> ResetToStateA();
private static bool IsImageFile(string path)
{
var ext = System.IO.Path.GetExtension(path).ToLowerInvariant();

View File

@@ -1,682 +0,0 @@
<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="TagOutline"
Width="52" Height="52"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
Margin="0,0,0,16"/>
<TextBlock Text="Drop a photo and click Identify"
FontSize="15" FontWeight="SemiBold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<TextBlock Text="Claude will identify the item, write a listing description&#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="CartOutline" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock Text="ITEM IDENTIFIED" Style="{StaticResource SectionHeading}"/>
</StackPanel>
<!-- Item name — large bold -->
<TextBlock x:Name="ItemNameText"
FontSize="20" FontWeight="Bold"
TextWrapping="Wrap" Margin="0,0,0,8"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
<!-- Brand/model pill badge -->
<Border x:Name="BrandPill"
Background="{DynamicResource MahApps.Brushes.Accent}"
CornerRadius="12"
Padding="10,3"
HorizontalAlignment="Left"
Margin="0,0,0,10"
Visibility="Collapsed">
<TextBlock x:Name="BrandModelText"
FontSize="11" FontWeight="SemiBold"
Foreground="White"/>
</Border>
<!-- Condition notes — green tinted box with eye icon -->
<Border Background="#1A4CAF50" BorderBrush="#4CAF50"
BorderThickness="1" CornerRadius="5" Padding="10,8">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Eye" Width="13" Height="13"
Margin="0,0,8,0" VerticalAlignment="Top"
Foreground="#4CAF50"/>
<TextBlock x:Name="ConditionText"
FontSize="12" TextWrapping="Wrap"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"
MaxWidth="340"/>
</StackPanel>
</Border>
<!-- Confidence badge (High / Medium / Low) -->
<Border x:Name="ConfidenceBadge"
CornerRadius="10" Padding="8,3"
HorizontalAlignment="Left"
Margin="0,10,0,0"
Visibility="Collapsed">
<TextBlock x:Name="ConfidenceText"
FontSize="10" FontWeight="SemiBold"
Foreground="White"/>
</Border>
<!-- Confidence notes -->
<TextBlock x:Name="ConfidenceNotesText"
FontSize="11" FontStyle="Italic"
TextWrapping="Wrap"
Margin="0,6,0,0"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Visibility="Collapsed"/>
</StackPanel>
</Border>
<!-- Price -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock Text="SUGGESTED PRICE" Style="{StaticResource SectionHeading}"/>
</StackPanel>
<!-- Prominent price badge -->
<Border HorizontalAlignment="Left"
Background="{DynamicResource MahApps.Brushes.Accent}"
CornerRadius="10"
Padding="18,8"
Margin="0,0,0,10">
<TextBlock x:Name="PriceSuggestedText"
FontSize="38" FontWeight="Bold"
Foreground="White"/>
</Border>
<!-- Min · Suggested · Max visual bar -->
<Grid x:Name="PriceRangeBar" Margin="0,0,0,12" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Connecting line -->
<Border Grid.Row="0" Height="2" Margin="12,0"
VerticalAlignment="Center"
Background="{DynamicResource MahApps.Brushes.Gray7}"
CornerRadius="1"/>
<!-- Three dots + labels -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Ellipse Grid.Column="0" Width="10" Height="10"
Fill="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"/>
<Ellipse Grid.Column="2" Width="14" Height="14"
Fill="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center"/>
<Ellipse Grid.Column="4" Width="10" Height="10"
Fill="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"/>
</Grid>
<!-- Labels row -->
<Grid Grid.Row="1" Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" HorizontalAlignment="Center">
<TextBlock Text="MIN" FontSize="9" FontWeight="SemiBold"
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
HorizontalAlignment="Center"/>
<TextBlock x:Name="PriceMinText" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
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.Gray6}"
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>
<!-- Live eBay price status -->
<StackPanel x:Name="LivePriceRow" Orientation="Horizontal"
Margin="0,6,0,0" Visibility="Collapsed">
<mah:ProgressRing x:Name="LivePriceSpinner"
Width="11" Height="11" Margin="0,0,6,0"/>
<TextBlock x:Name="LivePriceStatus"
FontSize="10"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"
TextWrapping="Wrap"/>
</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>
<!-- Corrections for AI refinement -->
<Border BorderThickness="1" CornerRadius="8" Padding="14,10"
Margin="0,0,0,10"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
Background="{DynamicResource MahApps.Brushes.Gray9}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<iconPacks:PackIconMaterial Kind="Pencil" Width="12" Height="12"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock Text="CORRECTIONS" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBox x:Name="CorrectionsBox"
AcceptsReturn="False"
TextWrapping="Wrap"
Height="52"
VerticalScrollBarVisibility="Auto"
Style="{DynamicResource MahApps.Styles.TextBox}"
FontSize="12"
mah:TextBoxHelper.Watermark="e.g. earrings are white gold with diamonds, not silver and zirconium"
Margin="0,0,0,8"/>
<Button x:Name="RefineBtn"
Click="Refine_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="32" Padding="12,0" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="RefineIcon" Kind="AutoFix" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<mah:ProgressRing x:Name="RefineSpinner" Width="13" Height="13"
Margin="0,0,6,0" Visibility="Collapsed"/>
<TextBlock x:Name="RefineBtnText" Text="Refine with AI"
VerticalAlignment="Center" FontSize="12"/>
</StackPanel>
</Button>
</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

@@ -1,710 +0,0 @@
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 EbayPriceResearchService? _priceService;
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,
EbayPriceResearchService priceService)
{
_aiService = aiService;
_savedService = savedService;
_priceService = priceService;
}
// ---- 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);
// Fire live price lookup in background — updates price display when ready
_ = UpdateLivePricesAsync(result.Title);
}
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 async void Refine_Click(object sender, RoutedEventArgs e)
{
if (_aiService == null || _lastResult == null) return;
var corrections = CorrectionsBox.Text.Trim();
if (string.IsNullOrEmpty(corrections))
{
CorrectionsBox.Focus();
return;
}
var title = TitleBox.Text;
var description = DescriptionBox.Text;
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
SetRefining(true);
try
{
var (newTitle, newDesc, newPrice, newReasoning) =
await _aiService.RefineWithCorrectionsAsync(title, description, price, corrections);
TitleBox.Text = newTitle;
DescriptionBox.Text = newDesc;
PriceOverride.Value = (double)Math.Round(newPrice, 2); // Issue 6
PriceSuggestedText.Text = newPrice > 0 ? $"£{newPrice:F2}" : "—";
_lastResult.Title = newTitle;
_lastResult.Description = newDesc;
_lastResult.PriceSuggested = newPrice;
if (!string.IsNullOrWhiteSpace(newReasoning))
{
PriceReasoningText.Text = newReasoning;
PriceReasoningText.Visibility = Visibility.Visible;
}
// Clear the corrections box now they're applied
CorrectionsBox.Text = "";
}
catch (Exception ex)
{
MessageBox.Show($"Refinement failed:\n\n{ex.Message}", "AI Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally
{
SetRefining(false);
}
}
private void SetRefining(bool busy)
{
RefineBtn.IsEnabled = !busy;
RefineIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
RefineSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
RefineBtnText.Text = busy ? "Refining…" : "Refine with AI";
}
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)Math.Round(r.PriceSuggested, 2); // Issue 6
// 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;
// Reset live price row until lookup completes
LivePriceRow.Visibility = Visibility.Collapsed;
// Animate results in
var sb = (Storyboard)FindResource("ResultsReveal");
sb.Begin(this);
}
private async Task UpdateLivePricesAsync(string query)
{
if (_priceService == null) return;
// Issue 7: guard against off-thread callers (fire-and-forget may lose sync context)
if (!Dispatcher.CheckAccess())
{
await Dispatcher.InvokeAsync(() => UpdateLivePricesAsync(query)).Task.Unwrap();
return;
}
try
{
// Issue 1: spinner-show inside try so a disposed control doesn't crash the caller
LivePriceRow.Visibility = Visibility.Visible;
LivePriceSpinner.Visibility = Visibility.Visible;
LivePriceStatus.Text = "Checking live eBay UK prices…";
var live = await _priceService.GetLivePricesAsync(query);
if (live.Count == 0)
{
LivePriceStatus.Text = "No matching live listings found.";
LivePriceSpinner.Visibility = Visibility.Collapsed;
return;
}
// Update range bar with real data
PriceMinText.Text = $"£{live.Min:F2}";
PriceMaxText.Text = $"£{live.Max:F2}";
PriceRangeBar.Visibility = Visibility.Visible;
// Update suggested price to 40th percentile (competitive but not cheapest)
var suggested = live.Suggested;
PriceSuggestedText.Text = $"£{suggested:F2}";
PriceOverride.Value = (double)Math.Round(suggested, 2); // Issue 6: avoid decimal→double drift
if (_lastResult != null) _lastResult.PriceSuggested = suggested;
// Update status label
LivePriceSpinner.Visibility = Visibility.Collapsed;
LivePriceStatus.Text =
$"Based on {live.Count} live eBay UK listing{(live.Count == 1 ? "" : "s")} " +
$"(range £{live.Min:F2} £{live.Max:F2})";
}
catch (Exception ex)
{
try
{
LivePriceSpinner.Visibility = Visibility.Collapsed;
LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}";
}
catch { /* control may be unloaded by the time catch runs */ }
}
}
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 void ShowSaveToast()
{
// Issue 8: always restart — stop any in-progress hold timer and cancel the running
// animation so the flag can never get permanently stuck and rapid saves feel responsive.
_holdTimer?.Stop();
_holdTimer = null;
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, null); // cancel current animation
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;
};
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

@@ -2,7 +2,8 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks">
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
KeyboardNavigation.TabNavigation="Cycle">
<UserControl.Resources>
@@ -41,6 +42,22 @@
<Setter Property="Height" Value="30"/>
</Style>
<!-- Shared style for detail action buttons -->
<Style x:Key="DetailActionButton" TargetType="Button"
BasedOn="{StaticResource MahApps.Styles.Button.Square.Accent}">
<Setter Property="Height" Value="34"/>
<Setter Property="Padding" Value="14,0"/>
<Setter Property="Margin" Value="0,0,8,6"/>
</Style>
<!-- Shared style for secondary detail action buttons -->
<Style x:Key="DetailSecondaryButton" TargetType="Button"
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
<Setter Property="Height" Value="34"/>
<Setter Property="Padding" Value="12,0"/>
<Setter Property="Margin" Value="0,0,8,6"/>
</Style>
</UserControl.Resources>
<Grid>
@@ -51,7 +68,7 @@
</Grid.ColumnDefinitions>
<!-- ================================================================
LEFT Listings list
LEFT - Listings list
================================================================ -->
<Grid Grid.Column="0">
<Grid.RowDefinitions>
@@ -72,7 +89,8 @@
<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}"/>
Foreground="{DynamicResource MahApps.Brushes.Accent}"
IsTabStop="False"/>
<TextBlock x:Name="ListingCountText" Text="0 saved listings"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"
@@ -81,7 +99,8 @@
<Button Grid.Column="1" x:Name="OpenExportsDirBtn"
Click="OpenExportsDir_Click"
Style="{StaticResource CardActionBtn}"
ToolTip="Open exports folder in Explorer">
ToolTip="Open exports folder in Explorer"
AutomationProperties.Name="Open exports folder in Explorer">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
@@ -104,18 +123,20 @@
Width="13" Height="13"
Margin="0,0,7,0"
VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
IsTabStop="False"/>
<TextBox Grid.Column="1" x:Name="SearchBox"
Style="{StaticResource SearchBox}"
mah:TextBoxHelper.Watermark="Filter listings"
mah:TextBoxHelper.Watermark="Filter listings..."
mah:TextBoxHelper.ClearTextButton="True"
TextChanged="SearchBox_TextChanged"/>
TextChanged="SearchBox_TextChanged"
AutomationProperties.Name="Filter saved listings"/>
</Grid>
</Border>
<!-- Card list -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
Padding="10,8">
Padding="10,8" Focusable="False">
<Grid>
<!-- Empty state for no saved listings -->
<StackPanel x:Name="EmptyCardState"
@@ -128,11 +149,13 @@
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}"/>
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
IsTabStop="False"/>
</Border>
<TextBlock Text="No saved listings yet"
FontSize="13" FontWeight="SemiBold"
@@ -158,7 +181,8 @@
Width="36" Height="36"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
Margin="0,0,0,12"/>
Margin="0,0,0,12"
IsTabStop="False"/>
<TextBlock Text="No listings match your search"
FontSize="12"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
@@ -172,10 +196,11 @@
<!-- Splitter -->
<GridSplitter Grid.Column="1" Width="4" HorizontalAlignment="Stretch"
Background="{DynamicResource MahApps.Brushes.Gray8}"/>
Background="{DynamicResource MahApps.Brushes.Gray8}"
AutomationProperties.Name="Resize listings panel"/>
<!-- ================================================================
RIGHT Detail panel
RIGHT - Detail panel
================================================================ -->
<Grid Grid.Column="2">
@@ -185,7 +210,8 @@
<iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="48" Height="48"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
Margin="0,0,0,14"/>
Margin="0,0,0,14"
IsTabStop="False"/>
<TextBlock Text="Select a saved listing" FontSize="14"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
HorizontalAlignment="Center"/>
@@ -193,7 +219,8 @@
<!-- Detail content -->
<ScrollViewer x:Name="DetailPanel" Visibility="Collapsed" Opacity="0"
VerticalScrollBarVisibility="Auto" Padding="18,14">
VerticalScrollBarVisibility="Auto" Padding="18,14"
Focusable="False">
<StackPanel>
<!-- Title + price row -->
@@ -219,7 +246,8 @@
<Button x:Name="RevalueBtn" Click="RevalueBtn_Click"
Height="28" Padding="8,0" Margin="6,0,0,0"
Style="{DynamicResource MahApps.Styles.Button.Square}"
ToolTip="Quick-change the price">
ToolTip="Quick-change the price"
AutomationProperties.Name="Quick-change the price">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="11" Height="11"
Margin="0,0,4,0" VerticalAlignment="Center"/>
@@ -235,11 +263,13 @@
<mah:NumericUpDown x:Name="RevaluePrice"
Minimum="0" Maximum="99999"
StringFormat="F2" Interval="0.5"
Width="110" Height="30"/>
Width="110" Height="30"
AutomationProperties.Name="New price value"/>
<Button x:Name="CheckEbayBtn" Click="CheckEbayBtn_Click"
Height="30" Padding="8,0" Margin="6,0,4,0"
Style="{DynamicResource MahApps.Styles.Button.Square}"
ToolTip="Check eBay for a suggested price">
ToolTip="Check eBay for a suggested price"
AutomationProperties.Name="Check eBay for suggested price">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="CheckEbayIcon"
Kind="Magnify" Width="11" Height="11"
@@ -251,13 +281,15 @@
<Button Click="RevalueSave_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="30" Padding="10,0" Margin="0,0,4,0"
ToolTip="Save new price">
ToolTip="Save new price"
AutomationProperties.Name="Save new price">
<iconPacks:PackIconMaterial Kind="Check" Width="13" Height="13"/>
</Button>
<Button Click="RevalueCancel_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="30" Padding="8,0"
ToolTip="Cancel">
ToolTip="Cancel"
AutomationProperties.Name="Cancel price change">
<iconPacks:PackIconMaterial Kind="Close" Width="11" Height="11"/>
</Button>
</StackPanel>
@@ -270,20 +302,22 @@
</StackPanel>
</Grid>
<!-- Meta row: category · date -->
<!-- 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}"/>
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
IsTabStop="False"/>
<TextBlock x:Name="DetailCategory" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
VerticalAlignment="Center"/>
<TextBlock Text=" · " FontSize="11"
<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}"/>
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
IsTabStop="False"/>
<TextBlock x:Name="DetailDate" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
VerticalAlignment="Center"/>
@@ -293,6 +327,7 @@
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Focusable="False"
Margin="0,0,0,4">
<WrapPanel x:Name="DetailPhotosPanel" Orientation="Horizontal"/>
</ScrollViewer>
@@ -320,22 +355,23 @@
<WrapPanel Orientation="Horizontal">
<Button x:Name="PostDraftBtn"
Click="PostDraft_Click"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6"
ToolTip="Post this draft to eBay">
Style="{StaticResource DetailActionButton}"
ToolTip="Post this draft to eBay"
AutomationProperties.Name="Post draft to eBay">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="PostDraftIcon"
Kind="CartArrowRight" Width="14" Height="14"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="PostDraftSpinner"
Width="14" Height="14" Margin="0,0,6,0"
Visibility="Collapsed"/>
Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Click="EditListing_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6">
Style="{StaticResource DetailActionButton}"
AutomationProperties.Name="Edit this listing">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Pencil" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
@@ -343,8 +379,8 @@
</StackPanel>
</Button>
<Button Click="OpenFolderDetail_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6">
Style="{StaticResource DetailActionButton}"
AutomationProperties.Name="Open export folder for this listing">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
@@ -352,8 +388,8 @@
</StackPanel>
</Button>
<Button Click="CopyTitle_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="12,0" Margin="0,0,8,6">
Style="{StaticResource DetailSecondaryButton}"
AutomationProperties.Name="Copy listing title to clipboard">
<Button.Content>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
@@ -363,8 +399,8 @@
</Button.Content>
</Button>
<Button Click="CopyDescription_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="12,0" Margin="0,0,8,6">
Style="{StaticResource DetailSecondaryButton}"
AutomationProperties.Name="Copy listing description to clipboard">
<Button.Content>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
@@ -374,8 +410,9 @@
</Button.Content>
</Button>
<Button Click="DeleteListing_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="12,0" Margin="0,0,0,6">
Style="{StaticResource MahApps.Styles.Button.Square}"
Height="34" Padding="12,0" Margin="0,0,0,6"
AutomationProperties.Name="Delete this listing">
<Button.Content>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13"
@@ -391,15 +428,17 @@
</StackPanel>
</ScrollViewer>
<!-- Edit panel shown in place of DetailPanel when editing -->
<!-- Edit panel - shown in place of DetailPanel when editing -->
<ScrollViewer x:Name="EditPanel" Visibility="Collapsed"
VerticalScrollBarVisibility="Auto" Padding="18,14">
<StackPanel>
VerticalScrollBarVisibility="Auto" Padding="18,14"
Focusable="False">
<StackPanel KeyboardNavigation.TabNavigation="Local">
<!-- Title -->
<TextBlock Text="TITLE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock x:Name="EditTitleLabel" Text="TITLE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditTitle" FontSize="13" Margin="0,0,0,4"
mah:TextBoxHelper.Watermark="Listing title"/>
mah:TextBoxHelper.Watermark="Listing title"
AutomationProperties.LabeledBy="{Binding ElementName=EditTitleLabel}"/>
<!-- Price + Category -->
<Grid Margin="0,0,0,4">
@@ -409,35 +448,40 @@
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="PRICE (£)" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock x:Name="EditPriceLabel" Text="PRICE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<mah:NumericUpDown x:Name="EditPrice" Minimum="0" Maximum="99999"
StringFormat="F2" Interval="0.5" Value="0"/>
StringFormat="F2" Interval="0.5" Value="0"
AutomationProperties.LabeledBy="{Binding ElementName=EditPriceLabel}"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="CATEGORY" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock x:Name="EditCategoryLabel" Text="CATEGORY" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCategory" FontSize="12"
mah:TextBoxHelper.Watermark="e.g. Clothing, Shoes &amp; Accessories"/>
mah:TextBoxHelper.Watermark="e.g. Clothing, Shoes &amp; Accessories"
AutomationProperties.LabeledBy="{Binding ElementName=EditCategoryLabel}"/>
</StackPanel>
</Grid>
<!-- Condition notes -->
<TextBlock Text="CONDITION NOTES" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock x:Name="EditConditionLabel" Text="CONDITION NOTES" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCondition" FontSize="12" Margin="0,0,0,4"
mah:TextBoxHelper.Watermark="Optional e.g. minor scuff on base"/>
mah:TextBoxHelper.Watermark="Optional - e.g. minor scuff on base"
AutomationProperties.LabeledBy="{Binding ElementName=EditConditionLabel}"/>
<!-- Description -->
<TextBlock Text="DESCRIPTION" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock x:Name="EditDescriptionLabel" Text="DESCRIPTION" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditDescription" FontSize="12" Margin="0,0,0,4"
TextWrapping="Wrap" AcceptsReturn="True"
Height="130" VerticalScrollBarVisibility="Auto"/>
Height="130" VerticalScrollBarVisibility="Auto"
AutomationProperties.LabeledBy="{Binding ElementName=EditDescriptionLabel}"/>
<!-- Photos -->
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock Text="First photo is the listing cover. Use ◀ ▶ to reorder."
<TextBlock Text="First photo is the listing cover. Use arrows to reorder."
FontSize="10" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,6"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Focusable="False"
Margin="0,0,0,10">
<StackPanel x:Name="EditPhotosPanel" Orientation="Horizontal"/>
</ScrollViewer>
@@ -447,11 +491,13 @@
<Button x:Name="SaveEditBtn" Click="SaveEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="16,0" Margin="0,0,8,0"
Content="Save Changes"/>
Content="Save Changes"
AutomationProperties.Name="Save listing changes"/>
<Button x:Name="CancelEditBtn" Click="CancelEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="14,0"
Content="Cancel"/>
Content="Cancel"
AutomationProperties.Name="Cancel editing"/>
</StackPanel>
</StackPanel>
@@ -470,6 +516,7 @@
BorderThickness="0,0,0,3"
BorderBrush="{DynamicResource MahApps.Brushes.Accent}"
Panel.ZIndex="10">
<Border.RenderTransform>
<TranslateTransform x:Name="ToastTranslate" Y="60"/>
</Border.RenderTransform>
@@ -482,14 +529,16 @@
<iconPacks:PackIconMaterial Kind="CheckCircleOutline"
Width="16" Height="16" Margin="0,0,10,0"
Foreground="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center"/>
VerticalAlignment="Center"
IsTabStop="False"/>
<TextBlock x:Name="ToastUrlText" Grid.Column="1"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource MahApps.Brushes.Gray1}" FontSize="12"/>
<Button Grid.Column="2" Content="" Width="20" Height="20"
<Button Grid.Column="2" Content="x" Width="20" Height="20"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Click="DismissToast_Click" Margin="8,0,0,0"/>
Click="DismissToast_Click" Margin="8,0,0,0"
AutomationProperties.Name="Dismiss notification"/>
</Grid>
</Border>

View File

@@ -778,14 +778,14 @@ public partial class SavedListingsView : UserControl
var draft = _selected.ToListingDraft();
var url = await _ebayListing.PostListingAsync(draft);
ToastUrlText.Text = url;
ShowDraftPostedToast();
var posted = _selected;
_selected = null;
_service?.Delete(posted);
ClearDetail();
RefreshList();
ToastUrlText.Text = url;
ShowDraftPostedToast();
}
catch (Exception ex)
{
@@ -810,7 +810,11 @@ public partial class SavedListingsView : UserControl
private void ShowDraftPostedToast()
{
_toastTimer?.Stop();
if (_toastTimer != null)
{
_toastTimer.Stop();
_toastTimer = null;
}
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, null);
DraftPostedToast.Visibility = Visibility.Visible;
@@ -835,6 +839,7 @@ public partial class SavedListingsView : UserControl
private void DismissToastAnimated()
{
_toastTimer?.Stop();
_toastTimer = null;
var slideOut = new System.Windows.Media.Animation.DoubleAnimation(
0, 60, new Duration(TimeSpan.FromMilliseconds(180)))
{

View File

@@ -1,566 +0,0 @@
<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="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center"
Cursor="Hand" TextDecorations="Underline"
MouseLeftButtonUp="ListingUrl_Click"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="Copy URL" Height="24" Padding="8,0" FontSize="11"
Click="CopyUrl_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
ToolTip="Copy listing URL to clipboard"/>
<Button Content="Copy Title" Height="24" Padding="8,0" FontSize="11"
Margin="6,0,0,0" Click="CopyTitle_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
ToolTip="Copy listing title to clipboard"/>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ================================================================
RIGHT COLUMN — photos panel
================================================================ -->
<Grid Grid.Column="2">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header: label + count badge -->
<Grid Grid.Row="0" Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ImageMultiple" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock Text="PHOTOS" Style="{StaticResource SectionHeading}"/>
</StackPanel>
<!-- Photo count badge -->
<Border Grid.Column="1" CornerRadius="10" Padding="8,2"
Background="{DynamicResource MahApps.Brushes.Gray8}">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="PhotoCountBadge" Text="0"
FontSize="12" FontWeight="Bold"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
<TextBlock Text=" / 12" FontSize="12"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
</StackPanel>
</Border>
</Grid>
<!-- Drop zone with dashed border, hover highlight -->
<Border Grid.Row="1"
x:Name="DropZone"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
BorderThickness="2" CornerRadius="4"
MinHeight="220"
AllowDrop="True" Drop="Photos_Drop" DragOver="Photos_DragOver"
DragEnter="DropZone_DragEnter" DragLeave="DropZone_DragLeave"
Background="{DynamicResource MahApps.Brushes.Gray10}">
<Border.Resources>
<!-- Dashed border via VisualBrush trickery is complex in WPF;
we use a solid thin border with hover accent colour instead -->
</Border.Resources>
<Grid>
<!-- Empty-state hint (hidden once photos added) -->
<StackPanel x:Name="DropHint" VerticalAlignment="Center"
HorizontalAlignment="Center"
IsHitTestVisible="False">
<iconPacks:PackIconMaterial Kind="ImagePlus"
Width="40" Height="40"
Margin="0,0,0,8"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
<TextBlock Text="Drag &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

@@ -1,694 +0,0 @@
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 bool _suppressCategoryLookup;
private string _suggestedPriceValue = "";
// Photo drag-reorder
private Point _dragStartPoint;
private bool _isDragging;
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 async 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;
// Auto-fill the top eBay category from the analysis keyword; user can override
await AutoFillCategoryAsync(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('"');
// Auto-fill category from the generated title if not already set
if (string.IsNullOrWhiteSpace(_draft.CategoryId))
await AutoFillCategoryAsync(TitleBox.Text);
}
catch (Exception ex)
{
ShowError("AI Title", ex.Message);
}
finally { SetBusy(false); SetTitleSpinner(false); }
}
// ---- Category ----
private async void CategoryBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (_suppressCategoryLookup) return;
_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;
}
}
/// <summary>
/// Fetches the top eBay category suggestion for <paramref name="keyword"/> and auto-fills
/// the category fields. The suggestions list is shown so the user can override.
/// </summary>
private async Task AutoFillCategoryAsync(string keyword)
{
if (_categoryService == null || string.IsNullOrWhiteSpace(keyword)) return;
try
{
var suggestions = await _categoryService.GetCategorySuggestionsAsync(keyword);
if (suggestions.Count == 0) return;
var top = suggestions[0];
_suppressCategoryLookup = true;
try
{
_draft.CategoryId = top.CategoryId;
_draft.CategoryName = top.CategoryName;
CategoryBox.Text = top.CategoryName;
CategoryIdLabel.Text = $"ID: {top.CategoryId}";
}
finally { _suppressCategoryLookup = false; }
// Show the full list so user can see alternatives and override
CategorySuggestionsList.ItemsSource = suggestions;
CategorySuggestionsList.Visibility = suggestions.Count > 1
? Visibility.Visible : Visibility.Collapsed;
}
catch { /* non-critical — leave category blank if lookup fails */ }
}
// ---- 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);
}
RebuildPhotoThumbnails();
}
/// <summary>
/// Clears and recreates all photo thumbnails from <see cref="ListingDraft.PhotoPaths"/>.
/// Called after any add, remove, or reorder operation so the panel always matches the list.
/// </summary>
private void RebuildPhotoThumbnails()
{
PhotosPanel.Children.Clear();
for (int i = 0; i < _draft.PhotoPaths.Count; i++)
AddPhotoThumbnail(_draft.PhotoPaths[i], i);
UpdatePhotoPanel();
}
private void AddPhotoThumbnail(string path, int index)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 128;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
var img = new System.Windows.Controls.Image
{
Width = 72, Height = 72,
Stretch = System.Windows.Media.Stretch.UniformToFill,
Source = bmp,
ToolTip = Path.GetFileName(path)
};
img.Clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 72, 72), 4, 4);
// Remove button
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
};
removeBtn.Click += (s, e) =>
{
e.Handled = true; // don't bubble and trigger drag
_draft.PhotoPaths.Remove(path);
RebuildPhotoThumbnails();
};
// "Cover" badge on the first photo — it becomes the eBay gallery hero image
Border? coverBadge = null;
if (index == 0)
{
coverBadge = new Border
{
CornerRadius = new CornerRadius(3),
Background = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromArgb(210, 60, 90, 200)),
Padding = new Thickness(3, 1, 3, 1),
Margin = new Thickness(2, 2, 0, 0),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
IsHitTestVisible = false, // don't block drag
Child = new TextBlock
{
Text = "Cover",
FontSize = 8,
FontWeight = FontWeights.SemiBold,
Foreground = System.Windows.Media.Brushes.White
}
};
}
var container = new Grid
{
Width = 72, Height = 72,
Margin = new Thickness(4),
Cursor = Cursors.SizeAll, // signal draggability
AllowDrop = true,
Tag = path // stable identifier used by drop handler
};
container.Children.Add(img);
if (coverBadge != null) container.Children.Add(coverBadge);
container.Children.Add(removeBtn);
// Hover: reveal remove button
container.MouseEnter += (s, e) => removeBtn.Opacity = 1;
container.MouseLeave += (s, e) => removeBtn.Opacity = 0;
// Drag initiation
container.MouseLeftButtonDown += (s, e) =>
{
_dragStartPoint = e.GetPosition(null);
};
container.MouseMove += (s, e) =>
{
if (e.LeftButton != MouseButtonState.Pressed || _isDragging) return;
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStartPoint.X) > SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(pos.Y - _dragStartPoint.Y) > SystemParameters.MinimumVerticalDragDistance)
{
_isDragging = true;
DragDrop.DoDragDrop(container, path, DragDropEffects.Move);
_isDragging = false;
}
};
// Drop target
container.DragOver += (s, e) =>
{
if (e.Data.GetDataPresent(typeof(string)) &&
(string)e.Data.GetData(typeof(string)) != path)
{
e.Effects = DragDropEffects.Move;
container.Opacity = 0.45; // dim to signal insertion point
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
};
container.DragLeave += (s, e) => container.Opacity = 1.0;
container.Drop += (s, e) =>
{
container.Opacity = 1.0;
if (!e.Data.GetDataPresent(typeof(string))) return;
var sourcePath = (string)e.Data.GetData(typeof(string));
var targetPath = (string)container.Tag;
if (sourcePath == targetPath) return;
var sourceIdx = _draft.PhotoPaths.IndexOf(sourcePath);
var targetIdx = _draft.PhotoPaths.IndexOf(targetPath);
if (sourceIdx < 0 || targetIdx < 0) return;
_draft.PhotoPaths.RemoveAt(sourceIdx);
_draft.PhotoPaths.Insert(targetIdx, sourcePath);
RebuildPhotoThumbnails();
e.Handled = true;
};
PhotosPanel.Children.Add(container);
}
catch { /* skip unreadable files */ }
}
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();
RebuildPhotoThumbnails();
}
// ---- 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B