2 Commits

Author SHA1 Message Date
Peter Foster
a9cfb7f613 Fix NullReferenceException in PriceSliderCard_ValueChanged
Slider ValueChanged fires during XAML init before named controls exist.
Guard with IsLoaded check, same pattern as UpdateCardTitleBar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:06:17 +01:00
Peter Foster
b3ef79e495 Add dyscalculia-friendly UI: card preview, verbal prices, relative dates
- NumberWords helper: decimal → "about seventeen pounds", DateTime → "3 days ago"
- PhotoAnalysisView: after analysis shows a card preview with large verbal price,
  photo dots, "Looks good ✓" to save instantly, "Change something ▼" to reveal
  a price slider (snaps to 50p, updates verbally as you drag) and title bar
- Card preview updates when live eBay price lookup completes
- SavedListingsView cards: verbal price as primary, £x.xx small beneath,
  relative date ("yesterday", "3 days ago") instead of raw timestamp
- Detail panel also shows relative date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:00:36 +01:00
23 changed files with 3370 additions and 1905 deletions

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
namespace EbayListingTool.Helpers;
public static class NumberWords
{
private static readonly string[] Ones =
[
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
"seventeen", "eighteen", "nineteen"
];
private static readonly string[] Tens =
[
"", "", "twenty", "thirty", "forty", "fifty",
"sixty", "seventy", "eighty", "ninety"
];
/// <summary>
/// Converts a price to a friendly verbal string.
/// £17.49 → "about seventeen pounds"
/// £17.50 → "about seventeen pounds fifty"
/// £0.50 → "fifty pence"
/// </summary>
public static string ToVerbalPrice(decimal price)
{
if (price <= 0) return "no price set";
// Snap to nearest 50p
var rounded = Math.Round(price * 2) / 2m;
int pounds = (int)rounded;
bool hasFifty = (rounded - pounds) >= 0.5m;
if (pounds == 0)
return "fifty pence";
var poundsWord = IntToWords(pounds);
var poundsLabel = pounds == 1 ? "pound" : "pounds";
var suffix = hasFifty ? " fifty" : "";
return $"about {poundsWord} {poundsLabel}{suffix}";
}
/// <summary>
/// Converts a UTC DateTime to a human-friendly relative string.
/// </summary>
public static string ToRelativeDate(DateTime utcTime)
{
var diff = DateTime.UtcNow - utcTime;
if (diff.TotalSeconds < 60) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} minutes ago";
if (diff.TotalHours < 2) return "about an hour ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hours ago";
if (diff.TotalDays < 2) return "yesterday";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays} days ago";
if (diff.TotalDays < 14) return "last week";
if (diff.TotalDays < 30) return $"{(int)(diff.TotalDays / 7)} weeks ago";
if (diff.TotalDays < 60) return "last month";
return $"{(int)(diff.TotalDays / 30)} months ago";
}
private static string IntToWords(int n)
{
if (n < 20) return Ones[n];
if (n < 100)
{
var t = Tens[n / 10];
var o = n % 10;
return o == 0 ? t : $"{t}-{Ones[o]}";
}
if (n < 1000)
{
var h = Ones[n / 100];
var rest = n % 100;
return rest == 0 ? $"{h} hundred" : $"{h} hundred and {IntToWords(rest)}";
}
return n.ToString(); // fallback for very large prices
}
}

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
namespace EbayListingTool.Models; using EbayListingTool.Helpers;
namespace EbayListingTool.Models;
public class SavedListing public class SavedListing
{ {
@@ -11,41 +13,16 @@ public class SavedListing
public string ConditionNotes { get; set; } = ""; public string ConditionNotes { get; set; } = "";
public string ExportFolder { get; set; } = ""; public string ExportFolder { get; set; } = "";
/// <summary>eBay category ID — stored at save time so we can post without re-looking it up.</summary>
public string CategoryId { get; set; } = "";
/// <summary>Item condition — defaults to Used for existing records without this field.</summary>
public ItemCondition Condition { get; set; } = ItemCondition.Used;
/// <summary>Listing format — defaults to FixedPrice for existing records.</summary>
public ListingFormat Format { get; set; } = ListingFormat.FixedPrice;
/// <summary>Seller postcode — populated from appsettings default at save time.</summary>
public string Postcode { get; set; } = "";
/// <summary>Absolute paths to photos inside ExportFolder.</summary> /// <summary>Absolute paths to photos inside ExportFolder.</summary>
public List<string> PhotoPaths { get; set; } = new(); public List<string> PhotoPaths { get; set; } = new();
public string FirstPhotoPath => PhotoPaths.Count > 0 ? PhotoPaths[0] : ""; public string FirstPhotoPath => PhotoPaths.Count > 0 ? PhotoPaths[0] : "";
public string PriceDisplay => Price > 0 ? $"\u00A3{Price:F2}" : "—"; public string PriceDisplay => Price > 0 ? $"£{Price:F2}" : "—";
public string PriceWords => NumberWords.ToVerbalPrice(Price);
public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm"); public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm");
/// <summary> public string SavedAtRelative => NumberWords.ToRelativeDate(SavedAt);
/// Converts this saved draft back into a ListingDraft suitable for PostListingAsync.
/// </summary>
public ListingDraft ToListingDraft() => new ListingDraft
{
Title = Title,
Description = Description,
Price = Price,
CategoryId = CategoryId,
CategoryName = Category,
Condition = Condition,
Format = Format,
Postcode = Postcode,
PhotoPaths = new List<string>(PhotoPaths),
Quantity = 1
};
} }

View File

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

View File

@@ -16,19 +16,11 @@ public class EbayListingService
private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs
private static readonly HttpClient _photoHttp = new(); // Trading API (photo upload) 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 // Per-session cache of eBay account IDs — fetched once, reused for every listing
private string? _fulfillmentPolicyId; private string? _fulfillmentPolicyId;
private string? _paymentPolicyId; private string? _paymentPolicyId;
private string? _returnPolicyId; private string? _returnPolicyId;
private string? _merchantLocationKey; private string? _merchantLocationKey;
private bool _triedPolicySetup;
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService) public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
{ {
@@ -43,17 +35,19 @@ public class EbayListingService
_paymentPolicyId = null; _paymentPolicyId = null;
_returnPolicyId = null; _returnPolicyId = null;
_merchantLocationKey = null; _merchantLocationKey = null;
_triedPolicySetup = false;
} }
public async Task<string> PostListingAsync(ListingDraft draft) public async Task<string> PostListingAsync(ListingDraft draft)
{ {
var token = await _auth.GetValidAccessTokenAsync(); var token = await _auth.GetValidAccessTokenAsync();
// Resolve business policies and merchant location before touching inventory/offers
await EnsurePoliciesAndLocationAsync(token, draft.Postcode); await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
// 1. Upload photos and get eBay-hosted URLs
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token); var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
// 2. Resolve category if not set
if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName)) if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName))
{ {
draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName) draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName)
@@ -63,8 +57,13 @@ public class EbayListingService
if (string.IsNullOrEmpty(draft.CategoryId)) if (string.IsNullOrEmpty(draft.CategoryId))
throw new InvalidOperationException("Please select a category before posting."); throw new InvalidOperationException("Please select a category before posting.");
// 3. Create inventory item
await CreateInventoryItemAsync(draft, imageUrls, token); await CreateInventoryItemAsync(draft, imageUrls, token);
// 4. Create offer
var offerId = await CreateOfferAsync(draft, token); var offerId = await CreateOfferAsync(draft, token);
// 5. Publish offer → get item ID
var itemId = await PublishOfferAsync(offerId, token); var itemId = await PublishOfferAsync(offerId, token);
draft.EbayItemId = itemId; draft.EbayItemId = itemId;
@@ -76,6 +75,11 @@ public class EbayListingService
// ---- Setup: policies + location ---- // ---- 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) private async Task EnsurePoliciesAndLocationAsync(string token, string postcode)
{ {
var baseUrl = _auth.BaseUrl; var baseUrl = _auth.BaseUrl;
@@ -88,17 +92,8 @@ public class EbayListingService
var json = await res.Content.ReadAsStringAsync(); var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
{
if (!_triedPolicySetup && json.Contains("20403"))
{
_triedPolicySetup = true;
await SetupDefaultBusinessPoliciesAsync(token);
await EnsurePoliciesAndLocationAsync(token, postcode);
return;
}
throw new HttpRequestException( throw new HttpRequestException(
$"Could not fetch fulfillment policies ({(int)res.StatusCode}): {json}"); $"Could not fetch fulfillment policies ({(int)res.StatusCode}): {json}");
}
var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray; var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray;
_fulfillmentPolicyId = arr?.Count > 0 _fulfillmentPolicyId = arr?.Count > 0
@@ -106,18 +101,9 @@ public class EbayListingService
: null; : null;
if (_fulfillmentPolicyId == null) if (_fulfillmentPolicyId == null)
{
if (!_triedPolicySetup)
{
_triedPolicySetup = true;
await SetupDefaultBusinessPoliciesAsync(token);
await EnsurePoliciesAndLocationAsync(token, postcode);
return;
}
throw new InvalidOperationException( throw new InvalidOperationException(
"No fulfillment policy found on your eBay account.\n\n" + "No fulfillment policy found on your eBay account.\n\n" +
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again."); "Please set one up in My eBay Account Business policies, then try again.");
}
} }
if (_paymentPolicyId == null) if (_paymentPolicyId == null)
@@ -139,7 +125,7 @@ public class EbayListingService
if (_paymentPolicyId == null) if (_paymentPolicyId == null)
throw new InvalidOperationException( throw new InvalidOperationException(
"No payment policy found on your eBay account.\n\n" + "No payment policy found on your eBay account.\n\n" +
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again."); "Please set one up in My eBay Account Business policies, then try again.");
} }
if (_returnPolicyId == null) if (_returnPolicyId == null)
@@ -161,7 +147,7 @@ public class EbayListingService
if (_returnPolicyId == null) if (_returnPolicyId == null)
throw new InvalidOperationException( throw new InvalidOperationException(
"No return policy found on your eBay account.\n\n" + "No return policy found on your eBay account.\n\n" +
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again."); "Please set one up in My eBay Account Business policies, then try again.");
} }
if (_merchantLocationKey == null) if (_merchantLocationKey == null)
@@ -179,6 +165,7 @@ public class EbayListingService
: null; : null;
} }
// No existing locations — create one from the seller's postcode
if (_merchantLocationKey == null) if (_merchantLocationKey == null)
{ {
await CreateMerchantLocationAsync(token, postcode); await CreateMerchantLocationAsync(token, postcode);
@@ -187,92 +174,10 @@ 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) private async Task CreateMerchantLocationAsync(string token, string postcode)
{ {
if (string.IsNullOrWhiteSpace(postcode)) if (string.IsNullOrWhiteSpace(postcode))
postcode = "N/A"; postcode = "N/A"; // eBay allows this when postcode is genuinely unknown
var body = new var body = new
{ {
@@ -309,7 +214,9 @@ public class EbayListingService
shipToLocationAvailability = new { quantity = draft.Quantity } shipToLocationAvailability = new { quantity = draft.Quantity }
}, },
condition = draft.ConditionId, condition = draft.ConditionId,
conditionDescription = draft.Condition == ItemCondition.Used ? "Used \u2014 see photos" : null, conditionDescription = draft.Condition == ItemCondition.Used ? "Used see photos" : null,
description = draft.Description,
title = draft.Title,
product = new product = new
{ {
title = draft.Title, title = draft.Title,
@@ -319,9 +226,14 @@ 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)}"; var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
using var req = MakeRequest(HttpMethod.Put, url, token); using var req = MakeRequest(HttpMethod.Put, url, token);
req.Content = new StringContent(JsonConvert.SerializeObject(inventoryItem, _jsonSettings), Encoding.UTF8, "application/json"); req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Content.Headers.Add("Content-Language", "en-GB"); req.Content.Headers.Add("Content-Language", "en-GB");
var res = await _http.SendAsync(req); var res = await _http.SendAsync(req);
@@ -350,14 +262,22 @@ public class EbayListingService
paymentPolicyId = _paymentPolicyId, paymentPolicyId = _paymentPolicyId,
returnPolicyId = _returnPolicyId 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, merchantLocationKey = _merchantLocationKey,
tax = new { vatPercentage = 0, applyTax = false } tax = new { vatPercentage = 0, applyTax = false }
}; };
var json = JsonConvert.SerializeObject(offer, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore
});
using var req = MakeRequest(HttpMethod.Post, using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/inventory/v1/offer", token); $"{_auth.BaseUrl}/sell/inventory/v1/offer", token);
req.Content = new StringContent(JsonConvert.SerializeObject(offer, _jsonSettings), Encoding.UTF8, "application/json"); req.Content = new StringContent(json, Encoding.UTF8, "application/json");
var res = await _http.SendAsync(req); var res = await _http.SendAsync(req);
var responseJson = await res.Content.ReadAsStringAsync(); var responseJson = await res.Content.ReadAsStringAsync();
@@ -391,22 +311,29 @@ public class EbayListingService
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token) private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
{ {
if (photoPaths.Count == 0) return []; var urls = new List<string>();
if (photoPaths.Count == 0) return urls;
var tradingBase = _auth.BaseUrl.Contains("sandbox") var tradingBase = _auth.BaseUrl.Contains("sandbox")
? "https://api.sandbox.ebay.com/ws/api.dll" ? "https://api.sandbox.ebay.com/ws/api.dll"
: "https://api.ebay.com/ws/api.dll"; : "https://api.ebay.com/ws/api.dll";
var semaphore = new SemaphoreSlim(4); foreach (var path in photoPaths.Take(12))
var tasks = photoPaths.Take(12).Select(async path =>
{ {
await semaphore.WaitAsync(); if (!File.Exists(path)) continue;
try { return await UploadSinglePhotoAsync(path, tradingBase, token); } try
catch { return null; } {
finally { semaphore.Release(); } var url = await UploadSinglePhotoAsync(path, tradingBase, token);
}); if (!string.IsNullOrEmpty(url))
urls.Add(url);
}
catch
{
// Skip failed photos; don't abort the whole listing
}
}
return [.. (await Task.WhenAll(tasks)).Where(u => !string.IsNullOrEmpty(u))]; return urls;
} }
private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token) private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
@@ -425,6 +352,7 @@ public class EbayListingService
</UploadSiteHostedPicturesRequest> </UploadSiteHostedPicturesRequest>
"""; """;
// Use HttpRequestMessage with _photoHttp so we don't create a new socket per photo
using var content = new MultipartFormDataContent(); using var content = new MultipartFormDataContent();
content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload"); content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
var imageContent = new ByteArrayContent(fileBytes); var imageContent = new ByteArrayContent(fileBytes);
@@ -448,6 +376,7 @@ public class EbayListingService
// ---- Helpers ---- // ---- Helpers ----
/// <summary>Creates a pre-authorised request targeting the eBay REST APIs.</summary>
private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token) private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
{ {
var req = new HttpRequestMessage(method, url); var req = new HttpRequestMessage(method, url);

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
<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

@@ -1,18 +0,0 @@
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,14 +1,13 @@
<mah:MetroWindow x:Class="EbayListingTool.Views.MainWindow" <mah:MetroWindow x:Class="EbayListingTool.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" 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"
xmlns:local="clr-namespace:EbayListingTool.Views" xmlns:local="clr-namespace:EbayListingTool.Views"
Title="eBay Listing Tool - UK" Title="eBay Listing Tool UK"
Height="820" Width="1180" Height="820" Width="1180"
MinHeight="600" MinWidth="900" MinHeight="600" MinWidth="900"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Icon="/EbayListingTool;component/app_icon.ico"
GlowBrush="{DynamicResource MahApps.Brushes.Accent}"> GlowBrush="{DynamicResource MahApps.Brushes.Accent}">
<mah:MetroWindow.Resources> <mah:MetroWindow.Resources>
@@ -60,62 +59,105 @@
</EventTrigger> </EventTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </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.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>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/> <RowDefinition Height="*"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Menu bar --> <TabControl x:Name="MainTabs" Grid.Row="0"
<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}"> Style="{DynamicResource MahApps.Styles.TabControl.Animated}">
<!-- New Listing tab --> <!-- ① Photo Analysis — always available, no eBay login needed -->
<TabItem Style="{StaticResource AppTabItem}">
<TabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Camera" Width="15" Height="15"
Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Photo Analyser" VerticalAlignment="Center"/>
</StackPanel>
</TabItem.Header>
<!-- Tab content: welcome banner + actual view stacked -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Welcome banner — only shown when no photo loaded yet (PhotoView sets Visibility via x:Name) -->
<Border x:Name="WelcomeBanner" Grid.Row="0"
Background="{DynamicResource MahApps.Brushes.Accent}"
Padding="14,7" Visibility="Visible">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="Camera" Width="14" Height="14"
Margin="0,0,8,0" VerticalAlignment="Center"
Foreground="White"/>
<TextBlock Text="Drop a photo to identify any item and get an instant eBay price"
Foreground="White" FontSize="12" FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<local:PhotoAnalysisView x:Name="PhotoView" Grid.Row="1"/>
</Grid>
</TabItem>
<!-- ② New Listing — requires eBay connection -->
<TabItem x:Name="NewListingTab" Style="{StaticResource AppTabItem}"> <TabItem x:Name="NewListingTab" Style="{StaticResource AppTabItem}">
<TabItem.Header> <TabItem.Header>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CameraPlus" <iconPacks:PackIconMaterial Kind="TagPlusOutline" Width="15" Height="15"
Style="{StaticResource TabHeaderIcon}"/> Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="New Listing" VerticalAlignment="Center"/> <TextBlock Text="New Listing" VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</TabItem.Header> </TabItem.Header>
<Grid> <Grid>
<local:NewListingView x:Name="NewListingView"/> <local:SingleItemView x:Name="SingleView"/>
<!-- Overlay when not connected to eBay --> <!-- Overlay shown when not connected -->
<Border x:Name="NewListingOverlay" Visibility="Visible" <Border x:Name="NewListingOverlay" Visibility="Visible"
Background="{DynamicResource MahApps.Brushes.ThemeBackground}"> Background="{DynamicResource MahApps.Brushes.ThemeBackground}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="340"> <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
MaxWidth="340">
<!-- eBay logo circle -->
<Border Width="72" Height="72" CornerRadius="36" <Border Width="72" Height="72" CornerRadius="36"
HorizontalAlignment="Center" Margin="0,0,0,18"> HorizontalAlignment="Center" Margin="0,0,0,18">
<Border.Background> <Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1"> <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#7C3AED" Offset="0"/> <GradientStop Color="#7C3AED" Offset="0"/>
@@ -125,21 +167,20 @@
<iconPacks:PackIconMaterial Kind="CartOutline" Width="32" Height="32" <iconPacks:PackIconMaterial Kind="CartOutline" Width="32" Height="32"
Foreground="White" Foreground="White"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"/>
/>
</Border> </Border>
<TextBlock Text="Connect to eBay" FontSize="20" FontWeight="Bold" <TextBlock Text="Connect to eBay"
FontSize="20" FontWeight="Bold"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.ThemeForeground}" Foreground="{DynamicResource MahApps.Brushes.ThemeForeground}"
Margin="0,0,0,8"/> Margin="0,0,0,8"/>
<TextBlock Text="Sign in with your eBay account to identify items, get prices, and post listings." <TextBlock Text="Sign in with your eBay account to start posting listings and managing your inventory."
FontSize="13" TextWrapping="Wrap" TextAlignment="Center" FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,24"/> Margin="0,0,0,24"/>
<Button x:Name="ConnectBtn" Click="ConnectBtn_Click" <Button Click="ConnectBtn_Click"
Style="{StaticResource LockConnectButton}" Style="{StaticResource LockConnectButton}"
HorizontalAlignment="Center" HorizontalAlignment="Center">
AutomationProperties.Name="Connect to eBay account">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14" <iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14"
Margin="0,0,7,0" VerticalAlignment="Center"/> Margin="0,0,7,0" VerticalAlignment="Center"/>
@@ -151,21 +192,73 @@
</Grid> </Grid>
</TabItem> </TabItem>
<!-- Drafts tab --> <!-- ③ Saved Listings — always available -->
<TabItem x:Name="DraftsTab" Style="{StaticResource AppTabItem}"> <TabItem x:Name="SavedTab" Style="{StaticResource AppTabItem}">
<TabItem.Header> <TabItem.Header>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="BookmarkMultiple" <iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="15" Height="15"
Style="{StaticResource TabHeaderIcon}"/> Margin="0,0,7,0" VerticalAlignment="Center"/>
<TextBlock Text="Drafts" VerticalAlignment="Center"/> <TextBlock Text="Saved Listings" VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</TabItem.Header> </TabItem.Header>
<local:SavedListingsView x:Name="SavedView"/> <local:SavedListingsView x:Name="SavedView"/>
</TabItem> </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> </TabControl>
<!-- Status bar --> <!-- Status bar -->
<Border Grid.Row="2" <Border Grid.Row="1"
Background="{DynamicResource MahApps.Brushes.Gray9}" Background="{DynamicResource MahApps.Brushes.Gray9}"
BorderThickness="0,1,0,0" BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"> BorderBrush="{DynamicResource MahApps.Brushes.Gray7}">
@@ -178,8 +271,7 @@
<iconPacks:PackIconMaterial Kind="AlertCircleOutline" <iconPacks:PackIconMaterial Kind="AlertCircleOutline"
Width="12" Height="12" Margin="0,0,5,0" Width="12" Height="12" Margin="0,0,5,0"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
/>
<TextBlock x:Name="StatusBar" Text="Ready" FontSize="11" <TextBlock x:Name="StatusBar" Text="Ready" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
@@ -190,14 +282,6 @@
<TextBlock x:Name="StatusBarEbay" Text="eBay: disconnected" <TextBlock x:Name="StatusBarEbay" Text="eBay: disconnected"
FontSize="11" VerticalAlignment="Center" FontSize="11" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/> 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> </StackPanel>
</Grid> </Grid>
</Border> </Border>

View File

@@ -31,13 +31,18 @@ public partial class MainWindow : MetroWindow
_priceService = new EbayPriceResearchService(_auth); _priceService = new EbayPriceResearchService(_auth);
_priceLookupService = new PriceLookupService(_priceService, _savedService, _aiService); _priceLookupService = new PriceLookupService(_priceService, _savedService, _aiService);
var defaultPostcode = config["Ebay:DefaultPostcode"] ?? ""; // Photo Analysis tab — no eBay needed
PhotoView.Initialise(_aiService, _savedService, _priceService);
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
NewListingView.Initialise(_listingService, _categoryService, _aiService, _auth, // Saved Listings tab
_savedService, defaultPostcode); SavedView.Initialise(_savedService, _priceLookupService);
SavedView.Initialise(_savedService, _priceLookupService, _listingService, _auth); // New Listing + Bulk tabs
SingleView.Initialise(_listingService, _categoryService, _aiService, _auth);
BulkView.Initialise(_listingService, _categoryService, _aiService, _bulkService, _auth);
// Try to restore saved eBay session
_auth.TryLoadSavedToken(); _auth.TryLoadSavedToken();
UpdateConnectionState(); UpdateConnectionState();
} }
@@ -47,7 +52,7 @@ public partial class MainWindow : MetroWindow
private async void ConnectBtn_Click(object sender, RoutedEventArgs e) private async void ConnectBtn_Click(object sender, RoutedEventArgs e)
{ {
ConnectBtn.IsEnabled = false; ConnectBtn.IsEnabled = false;
SetStatus("Connecting to eBay..."); SetStatus("Connecting to eBay");
try try
{ {
var username = await _auth.LoginAsync(); var username = await _auth.LoginAsync();
@@ -63,14 +68,14 @@ public partial class MainWindow : MetroWindow
finally finally
{ {
ConnectBtn.IsEnabled = true; ConnectBtn.IsEnabled = true;
UpdateConnectionState(); UpdateConnectionState(); // always sync UI to actual auth state
} }
} }
private void DisconnectBtn_Click(object sender, RoutedEventArgs e) private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
{ {
_auth.Disconnect(); _auth.Disconnect();
_listingService.ClearCache(); _listingService.ClearCache(); // clear cached policy/location IDs for next login
UpdateConnectionState(); UpdateConnectionState();
SetStatus("Disconnected from eBay."); SetStatus("Disconnected from eBay.");
} }
@@ -78,48 +83,50 @@ public partial class MainWindow : MetroWindow
private void UpdateConnectionState() private void UpdateConnectionState()
{ {
var connected = _auth.IsConnected; var connected = _auth.IsConnected;
// Per-tab overlays (Photo Analysis tab has no overlay)
NewListingOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible; 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) if (connected)
{ {
StatusDot.Fill = new SolidColorBrush(Colors.LimeGreen);
StatusLabel.Text = $"eBay: {_auth.ConnectedUsername}";
StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen); StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen);
StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}"; StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}";
StatusBarEbay.Foreground = new SolidColorBrush(Colors.LimeGreen); StatusBarEbay.Foreground = new SolidColorBrush(Colors.LimeGreen);
DisconnectBtn.Visibility = Visibility.Visible;
} }
else else
{ {
StatusDot.Fill = new SolidColorBrush(Colors.Gray);
StatusLabel.Text = "eBay: not connected";
StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88)); StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
StatusBarEbay.Text = "eBay: disconnected"; StatusBarEbay.Text = "eBay: disconnected";
StatusBarEbay.Foreground = (Brush)FindResource("MahApps.Brushes.Gray5"); StatusBarEbay.Foreground = (Brush)FindResource("MahApps.Brushes.Gray5");
DisconnectBtn.Visibility = Visibility.Collapsed;
} }
} }
// ---- File menu ---- // ---- Photo Analysis → New Listing handoff ----
private void BulkImport_Click(object sender, RoutedEventArgs e) private void OnUseDetailsRequested(PhotoAnalysisResult result, IReadOnlyList<string> photoPaths, decimal price)
{ {
if (!_auth.IsConnected) SingleView.PopulateFromAnalysis(result, photoPaths, price); // Q1: forward all photos
}
public void SwitchToNewListingTab()
{ {
MessageBox.Show("Please connect to eBay before using Bulk Import.", MainTabs.SelectedItem = NewListingTab;
"Not Connected", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var win = new BulkImportWindow(_listingService, _categoryService, _aiService, _bulkService, _auth);
win.Owner = this;
win.ShowDialog();
} }
private void Exit_Click(object sender, RoutedEventArgs e) => Close(); public void RefreshSavedListings()
{
SavedView.RefreshList();
}
// ---- Public interface for child views ---- // ---- Helpers ----
public void SetStatus(string message) => StatusBar.Text = message; 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,538 +0,0 @@
<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 -->
<Grid x:Name="StateA" Visibility="Visible">
<DockPanel LastChildFill="True">
<!-- Loading panel - shown while AI runs -->
<Border x:Name="LoadingPanel" DockPanel.Dock="Top"
Visibility="Collapsed"
Margin="60,30,60,0" Padding="30,40"
Background="{DynamicResource MahApps.Brushes.Gray9}"
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."
FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
<TextBlock Text="This usually takes 10-20 seconds"
Style="{StaticResource HintText}"
HorizontalAlignment="Center"
Margin="0,6,0,0"/>
</StackPanel>
</Border>
<!-- Drop zone -->
<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"
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}"
/>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
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"
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"
Style="{StaticResource HintText}"
HorizontalAlignment="Center"
Margin="0,6,0,0"/>
</StackPanel>
</Grid>
</Border>
<!-- Thumbnail strip -->
<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>
<!-- Analyse button -->
<StackPanel DockPanel.Dock="Top" HorizontalAlignment="Center" Margin="0,20,0,0">
<Button x:Name="AnalyseBtn"
Click="Analyse_Click"
IsEnabled="False"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
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"
IsTabStop="False"/>
<TextBlock x:Name="AnalyseBtnText"
Text="Identify &amp; Price with AI"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<TextBlock x:Name="PhotoCountLabel"
HorizontalAlignment="Center" Margin="0,8,0,0"
FontSize="13" Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
</StackPanel>
<Grid/> <!-- fill remaining space -->
</DockPanel>
</Grid>
<!-- STATE B: Review & Edit -->
<Grid x:Name="StateB" Visibility="Collapsed">
<DockPanel LastChildFill="True">
<!-- 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,784 +0,0 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using EbayListingTool.Models;
using EbayListingTool.Services;
using Microsoft.Win32;
namespace EbayListingTool.Views;
public partial class NewListingView : UserControl
{
// Services (injected via Initialise)
private EbayListingService? _listingService;
private EbayCategoryService? _categoryService;
private AiAssistantService? _aiService;
private EbayAuthService? _auth;
private SavedListingsService? _savedService;
private string _defaultPostcode = "";
// State A — photos
private readonly List<string> _photoPaths = new();
private const int MaxPhotos = 12;
// State B — draft being edited
private ListingDraft _draft = new();
private PhotoAnalysisResult? _lastAnalysis;
private bool _suppressCategoryLookup;
private System.Threading.CancellationTokenSource? _categoryCts;
private string _suggestedPriceValue = "";
// 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"
];
public NewListingView()
{
InitializeComponent();
_loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.5) };
_loadingTimer.Tick += (_, _) =>
{
_loadingStep = (_loadingStep + 1) % LoadingSteps.Length;
LoadingStepText.Text = LoadingSteps[_loadingStep];
};
}
private void UserControl_Loaded(object sender, RoutedEventArgs e) { }
public void Initialise(EbayListingService listingService, EbayCategoryService categoryService,
AiAssistantService aiService, EbayAuthService auth,
SavedListingsService savedService, string defaultPostcode)
{
_listingService = listingService;
_categoryService = categoryService;
_aiService = aiService;
_auth = auth;
_savedService = savedService;
_defaultPostcode = defaultPostcode;
}
// ---- State machine ----
private enum ListingState { DropZone, ReviewEdit, Success }
private void SetState(ListingState state)
{
StateA.Visibility = state == ListingState.DropZone ? Visibility.Visible : Visibility.Collapsed;
StateB.Visibility = state == ListingState.ReviewEdit ? Visibility.Visible : Visibility.Collapsed;
StateC.Visibility = state == ListingState.Success ? Visibility.Visible : Visibility.Collapsed;
}
// ---- State A: 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)
{
DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
e.Handled = true;
}
private void DropZone_DragLeave(object sender, DragEventArgs e)
{
DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray6");
}
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);
AddPhotos(files.Where(IsImageFile).ToArray());
}
private void DropZone_Click(object sender, MouseButtonEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Select photos of the item",
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*",
Multiselect = true
};
if (dlg.ShowDialog() == true)
AddPhotos(dlg.FileNames);
}
private void AddPhotos(string[] paths)
{
foreach (var path in paths)
{
if (_photoPaths.Count >= MaxPhotos) break;
if (_photoPaths.Contains(path)) continue;
if (!IsImageFile(path)) continue;
_photoPaths.Add(path);
}
UpdateThumbStrip();
UpdateAnalyseButton();
}
private void UpdateThumbStrip()
{
ThumbStrip.Children.Clear();
ThumbScroller.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
foreach (var path in _photoPaths)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 120;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
var img = new Image
{
Source = bmp, Width = 96, Height = 96,
Stretch = System.Windows.Media.Stretch.UniformToFill,
Margin = new Thickness(4)
};
img.Clip = new System.Windows.Media.RectangleGeometry(
new Rect(0, 0, 96, 96), 6, 6);
ThumbStrip.Children.Add(img);
}
catch { /* skip bad files */ }
}
PhotoCountLabel.Text = $"{_photoPaths.Count} / {MaxPhotos} photos";
PhotoCountLabel.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void UpdateAnalyseButton()
{
AnalyseBtn.IsEnabled = _photoPaths.Count > 0;
}
private async void Analyse_Click(object sender, RoutedEventArgs e)
{
if (_aiService == null || _photoPaths.Count == 0) return;
SetAnalysing(true);
try
{
var result = await _aiService.AnalyseItemFromPhotosAsync(_photoPaths);
_lastAnalysis = result;
await PopulateStateBAsync(result);
SetState(ListingState.ReviewEdit);
}
catch (Exception ex)
{
MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally
{
SetAnalysing(false);
}
}
private void SetAnalysing(bool busy)
{
AnalyseBtn.IsEnabled = !busy;
AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI";
LoadingPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
DropZoneBorder.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
if (busy)
{
_loadingStep = 0;
LoadingStepText.Text = LoadingSteps[0];
_loadingTimer.Start();
}
else
{
_loadingTimer.Stop();
}
}
// ---- 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();
}
public void ResetToStateA()
{
_photoPaths.Clear();
_draft = new ListingDraft { Postcode = _defaultPostcode };
_lastAnalysis = null;
UpdateThumbStrip();
UpdateAnalyseButton();
if (BPhotosPanel != null) BPhotosPanel.Children.Clear();
if (BTitleBox != null) BTitleBox.Text = "";
if (BDescBox != null) BDescBox.Text = "";
if (BCategoryBox != null) { BCategoryBox.Text = ""; BCategoryList.Visibility = Visibility.Collapsed; }
if (BCategoryIdLabel != null) BCategoryIdLabel.Text = "(no category selected)";
if (BPriceBox != null) BPriceBox.Value = 0;
if (BPriceHint != null) BPriceHint.Visibility = Visibility.Collapsed;
if (BConditionBox != null) BConditionBox.SelectedIndex = 3;
if (BFormatBox != null) BFormatBox.SelectedIndex = 0;
if (BPostcodeBox != null) BPostcodeBox.Text = _defaultPostcode;
SetState(ListingState.DropZone);
}
private 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();
return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
}
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
}

View File

@@ -0,0 +1,842 @@
<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>
<!-- ============================================================
Card Preview — dyscalculia-friendly quick-approve flow
============================================================ -->
<StackPanel x:Name="CardPreviewPanel" Visibility="Collapsed">
<!-- Item card -->
<Border CornerRadius="10" Padding="16" Margin="0,0,0,12"
Background="{DynamicResource MahApps.Brushes.Gray10}"
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}"
BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="110"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Cover photo -->
<Border Grid.Column="0" CornerRadius="7" ClipToBounds="True"
Width="110" Height="110">
<Image x:Name="CardPhoto" Stretch="UniformToFill"/>
</Border>
<!-- Details -->
<StackPanel Grid.Column="1" Margin="14,0,0,0"
VerticalAlignment="Center">
<TextBlock x:Name="CardItemName"
FontSize="17" FontWeight="Bold"
TextWrapping="Wrap"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
<TextBlock x:Name="CardCondition"
FontSize="11" Margin="0,3,0,0"
TextWrapping="Wrap"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<!-- Photo dots (built in code-behind) -->
<StackPanel x:Name="CardPhotoDots"
Orientation="Horizontal" Margin="0,8,0,0"/>
<!-- Verbal price — primary -->
<TextBlock x:Name="CardPriceVerbal"
FontSize="24" FontWeight="Bold" Margin="0,10,0,2"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<!-- Digit price — secondary/small -->
<TextBlock x:Name="CardPriceDigit"
FontSize="11" Opacity="0.40"/>
<!-- Category pill -->
<Border CornerRadius="10" Padding="8,3" Margin="0,10,0,0"
Background="{DynamicResource MahApps.Brushes.Gray9}"
HorizontalAlignment="Left">
<TextBlock x:Name="CardCategory" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- Live price note (updated async) -->
<TextBlock x:Name="CardLivePriceNote"
FontSize="10" Margin="0,0,0,10"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
TextWrapping="Wrap" Visibility="Collapsed"/>
<!-- Primary action buttons -->
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" x:Name="LooksGoodBtn"
Click="LooksGood_Click"
Height="54" FontSize="15" FontWeight="SemiBold"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Check" Width="17" Height="17"
Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Looks good" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="2" x:Name="ChangeSomethingBtn"
Click="ChangeSomething_Click"
Height="54" FontSize="13"
Style="{DynamicResource MahApps.Styles.Button.Square}">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="ChangeChevron"
Kind="ChevronDown" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Change something" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<!-- Change panel (collapsed by default) -->
<StackPanel x:Name="CardChangePanel" Visibility="Collapsed" Margin="0,4,0,0">
<!-- Price slider -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<TextBlock Text="PRICE" Style="{StaticResource SectionHeading}"
Margin="0,0,0,10"/>
<TextBlock x:Name="SliderVerbalLabel"
FontSize="22" FontWeight="Bold" Margin="0,0,0,2"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock x:Name="SliderDigitLabel"
FontSize="11" Opacity="0.40" Margin="0,0,0,12"/>
<Slider x:Name="PriceSliderCard"
Minimum="0.50" Maximum="200"
SmallChange="0.5" LargeChange="5"
TickFrequency="0.5" IsSnapToTickEnabled="True"
ValueChanged="PriceSliderCard_ValueChanged"/>
<Grid Margin="0,3,0,0">
<TextBlock Text="cheaper" FontSize="10" Opacity="0.45"
HorizontalAlignment="Left"/>
<TextBlock Text="pricier" FontSize="10" Opacity="0.45"
HorizontalAlignment="Right"/>
</Grid>
</StackPanel>
</Border>
<!-- Title edit -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<TextBlock Text="TITLE" Style="{StaticResource SectionHeading}"
Margin="0,0,0,8"/>
<TextBox x:Name="CardTitleBox"
TextWrapping="Wrap" AcceptsReturn="False"
MaxLength="80" FontSize="13"
TextChanged="CardTitleBox_TextChanged"/>
<!-- Colour bar — no digit counter -->
<Grid Margin="0,6,0,0" Height="4">
<Border CornerRadius="2" Background="{DynamicResource MahApps.Brushes.Gray8}"/>
<Border x:Name="CardTitleBar" CornerRadius="2"
HorizontalAlignment="Left" Width="0"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Grid>
</StackPanel>
</Border>
<!-- Save with changes -->
<Button Content="Save with changes"
Click="SaveWithChanges_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="46" FontSize="14" FontWeight="SemiBold"
HorizontalAlignment="Stretch" Margin="0,4,0,8"/>
<Button Content="Analyse another item"
Click="AnalyseAnother_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="36" FontSize="12"
HorizontalAlignment="Stretch"/>
</StackPanel>
</StackPanel>
<!-- 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

@@ -0,0 +1,834 @@
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.Helpers;
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.Collapsed; // hidden behind card preview
CardPreviewPanel.Visibility = Visibility.Visible;
CardChangePanel.Visibility = Visibility.Collapsed;
ChangeChevron.Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronDown;
// --- Populate card preview ---
CardItemName.Text = r.ItemName;
CardCondition.Text = r.ConditionNotes;
CardCategory.Text = r.CategoryKeyword;
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(r.PriceSuggested);
CardPriceDigit.Text = r.PriceSuggested > 0 ? $"£{r.PriceSuggested:F2}" : "";
CardLivePriceNote.Visibility = Visibility.Collapsed;
// Cover photo
if (_currentImagePaths.Count > 0)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(_currentImagePaths[0], UriKind.Absolute);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = 220;
bmp.EndInit();
bmp.Freeze();
CardPhoto.Source = bmp;
}
catch { CardPhoto.Source = null; }
}
// Photo dots — one dot per photo, filled accent for first
CardPhotoDots.Children.Clear();
for (int i = 0; i < _currentImagePaths.Count; i++)
{
CardPhotoDots.Children.Add(new System.Windows.Shapes.Ellipse
{
Width = 7, Height = 7,
Margin = new Thickness(2, 0, 2, 0),
Fill = i == 0
? (Brush)FindResource("MahApps.Brushes.Accent")
: (Brush)FindResource("MahApps.Brushes.Gray7")
});
}
CardPhotoDots.Visibility = _currentImagePaths.Count > 1
? Visibility.Visible : Visibility.Collapsed;
// Price slider — centre on suggested, range ±60% clamped to sensible bounds
var suggested = (double)(r.PriceSuggested > 0 ? r.PriceSuggested : 10m);
PriceSliderCard.Minimum = Math.Max(0.50, Math.Round(suggested * 0.4 * 2) / 2);
PriceSliderCard.Maximum = Math.Round(suggested * 1.8 * 2) / 2;
PriceSliderCard.Value = Math.Round(suggested * 2) / 2; // snap to 50p
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(r.PriceSuggested);
SliderDigitLabel.Text = $"£{r.PriceSuggested:F2}";
// Card title box
CardTitleBox.Text = r.Title;
UpdateCardTitleBar(r.Title.Length);
// 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;
}
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);
if (_lastResult != null) _lastResult.PriceSuggested = suggested;
// Update card preview price
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(suggested);
CardPriceDigit.Text = $"£{suggested:F2}";
var snapped = Math.Round((double)suggested * 2) / 2;
if (snapped >= PriceSliderCard.Minimum && snapped <= PriceSliderCard.Maximum)
PriceSliderCard.Value = snapped;
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(suggested);
SliderDigitLabel.Text = $"£{suggested:F2}";
// Show note on card
var noteText = $"eBay: {live.Count} similar listing{(live.Count == 1 ? "" : "s")}, range £{live.Min:F2}–£{live.Max:F2}";
CardLivePriceNote.Text = noteText;
CardLivePriceNote.Visibility = Visibility.Visible;
// 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);
}
// ---- Card preview handlers ----
private void LooksGood_Click(object sender, RoutedEventArgs e)
=> SaveListing_Click(sender, e);
private void ChangeSomething_Click(object sender, RoutedEventArgs e)
{
var expanding = CardChangePanel.Visibility != Visibility.Visible;
CardChangePanel.Visibility = expanding ? Visibility.Visible : Visibility.Collapsed;
ChangeChevron.Kind = expanding
? MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronUp
: MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronDown;
}
private void PriceSliderCard_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (!IsLoaded) return;
var price = (decimal)e.NewValue;
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(price);
SliderDigitLabel.Text = $"£{price:F2}";
// Keep card price display in sync
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(price);
CardPriceDigit.Text = $"£{price:F2}";
// Keep hidden ResultsPanel in sync so SaveListing_Click gets the right value
PriceOverride.Value = e.NewValue;
if (_lastResult != null) _lastResult.PriceSuggested = price;
}
private void CardTitleBox_TextChanged(object sender, TextChangedEventArgs e)
{
var len = CardTitleBox.Text.Length;
UpdateCardTitleBar(len);
// Keep hidden ResultsPanel in sync
TitleBox.Text = CardTitleBox.Text;
}
private void UpdateCardTitleBar(int len)
{
if (!IsLoaded) return;
var trackWidth = CardTitleBar.Parent is Grid g ? g.ActualWidth : 0;
if (trackWidth <= 0) return;
CardTitleBar.Width = trackWidth * (len / 80.0);
CardTitleBar.Background = len > 75
? System.Windows.Media.Brushes.OrangeRed
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
}
private void SaveWithChanges_Click(object sender, RoutedEventArgs e)
{
if (_lastResult != null)
{
_lastResult.Title = CardTitleBox.Text.Trim();
TitleBox.Text = _lastResult.Title;
}
SaveListing_Click(sender, e);
}
// ---- 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,8 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" 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> <UserControl.Resources>
@@ -42,22 +41,6 @@
<Setter Property="Height" Value="30"/> <Setter Property="Height" Value="30"/>
</Style> </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> </UserControl.Resources>
<Grid> <Grid>
@@ -68,7 +51,7 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- ================================================================ <!-- ================================================================
LEFT - Listings list LEFT Listings list
================================================================ --> ================================================================ -->
<Grid Grid.Column="0"> <Grid Grid.Column="0">
<Grid.RowDefinitions> <Grid.RowDefinitions>
@@ -89,8 +72,7 @@
<StackPanel Grid.Column="0" Orientation="Horizontal"> <StackPanel Grid.Column="0" Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="14" Height="14" <iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="14" Height="14"
Margin="0,0,7,0" VerticalAlignment="Center" 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" <TextBlock x:Name="ListingCountText" Text="0 saved listings"
FontSize="12" FontWeight="SemiBold" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource MahApps.Brushes.Gray2}" Foreground="{DynamicResource MahApps.Brushes.Gray2}"
@@ -99,8 +81,7 @@
<Button Grid.Column="1" x:Name="OpenExportsDirBtn" <Button Grid.Column="1" x:Name="OpenExportsDirBtn"
Click="OpenExportsDir_Click" Click="OpenExportsDir_Click"
Style="{StaticResource CardActionBtn}" 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"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="12" Height="12" <iconPacks:PackIconMaterial Kind="FolderOpen" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/> Margin="0,0,4,0" VerticalAlignment="Center"/>
@@ -123,20 +104,18 @@
Width="13" Height="13" Width="13" Height="13"
Margin="0,0,7,0" Margin="0,0,7,0"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
IsTabStop="False"/>
<TextBox Grid.Column="1" x:Name="SearchBox" <TextBox Grid.Column="1" x:Name="SearchBox"
Style="{StaticResource SearchBox}" Style="{StaticResource SearchBox}"
mah:TextBoxHelper.Watermark="Filter listings..." mah:TextBoxHelper.Watermark="Filter listings"
mah:TextBoxHelper.ClearTextButton="True" mah:TextBoxHelper.ClearTextButton="True"
TextChanged="SearchBox_TextChanged" TextChanged="SearchBox_TextChanged"/>
AutomationProperties.Name="Filter saved listings"/>
</Grid> </Grid>
</Border> </Border>
<!-- Card list --> <!-- Card list -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" <ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
Padding="10,8" Focusable="False"> Padding="10,8">
<Grid> <Grid>
<!-- Empty state for no saved listings --> <!-- Empty state for no saved listings -->
<StackPanel x:Name="EmptyCardState" <StackPanel x:Name="EmptyCardState"
@@ -149,13 +128,11 @@
HorizontalAlignment="Center" HorizontalAlignment="Center"
Margin="0,0,0,16" Margin="0,0,0,16"
Background="{DynamicResource MahApps.Brushes.Gray9}"> Background="{DynamicResource MahApps.Brushes.Gray9}">
<iconPacks:PackIconMaterial Kind="BookmarkPlusOutline" <iconPacks:PackIconMaterial Kind="BookmarkPlusOutline"
Width="32" Height="32" Width="32" Height="32"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
IsTabStop="False"/>
</Border> </Border>
<TextBlock Text="No saved listings yet" <TextBlock Text="No saved listings yet"
FontSize="13" FontWeight="SemiBold" FontSize="13" FontWeight="SemiBold"
@@ -181,8 +158,7 @@
Width="36" Height="36" Width="36" Height="36"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray6}" Foreground="{DynamicResource MahApps.Brushes.Gray6}"
Margin="0,0,0,12" Margin="0,0,0,12"/>
IsTabStop="False"/>
<TextBlock Text="No listings match your search" <TextBlock Text="No listings match your search"
FontSize="12" FontSize="12"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
@@ -196,11 +172,10 @@
<!-- Splitter --> <!-- Splitter -->
<GridSplitter Grid.Column="1" Width="4" HorizontalAlignment="Stretch" <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"> <Grid Grid.Column="2">
@@ -210,8 +185,7 @@
<iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="48" Height="48" <iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="48" Height="48"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray7}" 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" <TextBlock Text="Select a saved listing" FontSize="14"
Foreground="{DynamicResource MahApps.Brushes.Gray5}" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
@@ -219,8 +193,7 @@
<!-- Detail content --> <!-- Detail content -->
<ScrollViewer x:Name="DetailPanel" Visibility="Collapsed" Opacity="0" <ScrollViewer x:Name="DetailPanel" Visibility="Collapsed" Opacity="0"
VerticalScrollBarVisibility="Auto" Padding="18,14" VerticalScrollBarVisibility="Auto" Padding="18,14">
Focusable="False">
<StackPanel> <StackPanel>
<!-- Title + price row --> <!-- Title + price row -->
@@ -246,8 +219,7 @@
<Button x:Name="RevalueBtn" Click="RevalueBtn_Click" <Button x:Name="RevalueBtn" Click="RevalueBtn_Click"
Height="28" Padding="8,0" Margin="6,0,0,0" Height="28" Padding="8,0" Margin="6,0,0,0"
Style="{DynamicResource MahApps.Styles.Button.Square}" 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"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="11" Height="11" <iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="11" Height="11"
Margin="0,0,4,0" VerticalAlignment="Center"/> Margin="0,0,4,0" VerticalAlignment="Center"/>
@@ -263,13 +235,11 @@
<mah:NumericUpDown x:Name="RevaluePrice" <mah:NumericUpDown x:Name="RevaluePrice"
Minimum="0" Maximum="99999" Minimum="0" Maximum="99999"
StringFormat="F2" Interval="0.5" 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" <Button x:Name="CheckEbayBtn" Click="CheckEbayBtn_Click"
Height="30" Padding="8,0" Margin="6,0,4,0" Height="30" Padding="8,0" Margin="6,0,4,0"
Style="{DynamicResource MahApps.Styles.Button.Square}" 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"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="CheckEbayIcon" <iconPacks:PackIconMaterial x:Name="CheckEbayIcon"
Kind="Magnify" Width="11" Height="11" Kind="Magnify" Width="11" Height="11"
@@ -281,15 +251,13 @@
<Button Click="RevalueSave_Click" <Button Click="RevalueSave_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}" Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="30" Padding="10,0" Margin="0,0,4,0" 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"/> <iconPacks:PackIconMaterial Kind="Check" Width="13" Height="13"/>
</Button> </Button>
<Button Click="RevalueCancel_Click" <Button Click="RevalueCancel_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}" Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="30" Padding="8,0" Height="30" Padding="8,0"
ToolTip="Cancel" ToolTip="Cancel">
AutomationProperties.Name="Cancel price change">
<iconPacks:PackIconMaterial Kind="Close" Width="11" Height="11"/> <iconPacks:PackIconMaterial Kind="Close" Width="11" Height="11"/>
</Button> </Button>
</StackPanel> </StackPanel>
@@ -302,22 +270,20 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Meta row: category / date --> <!-- Meta row: category · date -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,14"> <StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<iconPacks:PackIconMaterial Kind="Tag" Width="11" Height="11" <iconPacks:PackIconMaterial Kind="Tag" Width="11" Height="11"
Margin="0,0,4,0" VerticalAlignment="Center" 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" <TextBlock x:Name="DetailCategory" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray4}" Foreground="{DynamicResource MahApps.Brushes.Gray4}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<TextBlock Text=" | " FontSize="11" <TextBlock Text=" · " FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray6}" Foreground="{DynamicResource MahApps.Brushes.Gray6}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<iconPacks:PackIconMaterial Kind="ClockOutline" Width="11" Height="11" <iconPacks:PackIconMaterial Kind="ClockOutline" Width="11" Height="11"
Margin="0,0,4,0" VerticalAlignment="Center" 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" <TextBlock x:Name="DetailDate" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray4}" Foreground="{DynamicResource MahApps.Brushes.Gray4}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
@@ -327,7 +293,6 @@
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}"/> <TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" <ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Disabled"
Focusable="False"
Margin="0,0,0,4"> Margin="0,0,0,4">
<WrapPanel x:Name="DetailPhotosPanel" Orientation="Horizontal"/> <WrapPanel x:Name="DetailPhotosPanel" Orientation="Horizontal"/>
</ScrollViewer> </ScrollViewer>
@@ -353,25 +318,9 @@
<!-- Action buttons --> <!-- Action buttons -->
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<Button x:Name="PostDraftBtn"
Click="PostDraft_Click"
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"
IsTabStop="False"/>
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Click="EditListing_Click" <Button Click="EditListing_Click"
Style="{StaticResource DetailActionButton}" Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
AutomationProperties.Name="Edit this listing"> Height="34" Padding="14,0" Margin="0,0,8,6">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Pencil" Width="13" Height="13" <iconPacks:PackIconMaterial Kind="Pencil" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/> Margin="0,0,6,0" VerticalAlignment="Center"/>
@@ -379,8 +328,8 @@
</StackPanel> </StackPanel>
</Button> </Button>
<Button Click="OpenFolderDetail_Click" <Button Click="OpenFolderDetail_Click"
Style="{StaticResource DetailActionButton}" Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
AutomationProperties.Name="Open export folder for this listing"> Height="34" Padding="14,0" Margin="0,0,8,6">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="13" Height="13" <iconPacks:PackIconMaterial Kind="FolderOpen" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/> Margin="0,0,6,0" VerticalAlignment="Center"/>
@@ -388,8 +337,8 @@
</StackPanel> </StackPanel>
</Button> </Button>
<Button Click="CopyTitle_Click" <Button Click="CopyTitle_Click"
Style="{StaticResource DetailSecondaryButton}" Style="{DynamicResource MahApps.Styles.Button.Square}"
AutomationProperties.Name="Copy listing title to clipboard"> Height="34" Padding="12,0" Margin="0,0,8,6">
<Button.Content> <Button.Content>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12" <iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
@@ -399,8 +348,8 @@
</Button.Content> </Button.Content>
</Button> </Button>
<Button Click="CopyDescription_Click" <Button Click="CopyDescription_Click"
Style="{StaticResource DetailSecondaryButton}" Style="{DynamicResource MahApps.Styles.Button.Square}"
AutomationProperties.Name="Copy listing description to clipboard"> Height="34" Padding="12,0" Margin="0,0,8,6">
<Button.Content> <Button.Content>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12" <iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
@@ -410,9 +359,8 @@
</Button.Content> </Button.Content>
</Button> </Button>
<Button Click="DeleteListing_Click" <Button Click="DeleteListing_Click"
Style="{StaticResource MahApps.Styles.Button.Square}" Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="12,0" Margin="0,0,0,6" Height="34" Padding="12,0" Margin="0,0,0,6">
AutomationProperties.Name="Delete this listing">
<Button.Content> <Button.Content>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13" <iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13"
@@ -428,17 +376,15 @@
</StackPanel> </StackPanel>
</ScrollViewer> </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" <ScrollViewer x:Name="EditPanel" Visibility="Collapsed"
VerticalScrollBarVisibility="Auto" Padding="18,14" VerticalScrollBarVisibility="Auto" Padding="18,14">
Focusable="False"> <StackPanel>
<StackPanel KeyboardNavigation.TabNavigation="Local">
<!-- Title --> <!-- Title -->
<TextBlock x:Name="EditTitleLabel" Text="TITLE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/> <TextBlock Text="TITLE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditTitle" FontSize="13" Margin="0,0,0,4" <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 --> <!-- Price + Category -->
<Grid Margin="0,0,0,4"> <Grid Margin="0,0,0,4">
@@ -448,40 +394,35 @@
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<StackPanel Grid.Column="0"> <StackPanel Grid.Column="0">
<TextBlock x:Name="EditPriceLabel" Text="PRICE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/> <TextBlock Text="PRICE (£)" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<mah:NumericUpDown x:Name="EditPrice" Minimum="0" Maximum="99999" <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>
<StackPanel Grid.Column="2"> <StackPanel Grid.Column="2">
<TextBlock x:Name="EditCategoryLabel" Text="CATEGORY" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/> <TextBlock Text="CATEGORY" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCategory" FontSize="12" <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> </StackPanel>
</Grid> </Grid>
<!-- Condition notes --> <!-- Condition notes -->
<TextBlock x:Name="EditConditionLabel" Text="CONDITION NOTES" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/> <TextBlock Text="CONDITION NOTES" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCondition" FontSize="12" Margin="0,0,0,4" <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 --> <!-- Description -->
<TextBlock x:Name="EditDescriptionLabel" Text="DESCRIPTION" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/> <TextBlock Text="DESCRIPTION" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditDescription" FontSize="12" Margin="0,0,0,4" <TextBox x:Name="EditDescription" FontSize="12" Margin="0,0,0,4"
TextWrapping="Wrap" AcceptsReturn="True" TextWrapping="Wrap" AcceptsReturn="True"
Height="130" VerticalScrollBarVisibility="Auto" Height="130" VerticalScrollBarVisibility="Auto"/>
AutomationProperties.LabeledBy="{Binding ElementName=EditDescriptionLabel}"/>
<!-- Photos --> <!-- Photos -->
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/> <TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock Text="First photo is the listing cover. Use arrows to reorder." <TextBlock Text="First photo is the listing cover. Use ◀ ▶ to reorder."
FontSize="10" Foreground="{DynamicResource MahApps.Brushes.Gray5}" FontSize="10" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,6"/> Margin="0,0,0,6"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" <ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Disabled"
Focusable="False"
Margin="0,0,0,10"> Margin="0,0,0,10">
<StackPanel x:Name="EditPhotosPanel" Orientation="Horizontal"/> <StackPanel x:Name="EditPhotosPanel" Orientation="Horizontal"/>
</ScrollViewer> </ScrollViewer>
@@ -491,56 +432,16 @@
<Button x:Name="SaveEditBtn" Click="SaveEdit_Click" <Button x:Name="SaveEditBtn" Click="SaveEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}" Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="16,0" Margin="0,0,8,0" 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" <Button x:Name="CancelEditBtn" Click="CancelEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}" Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="14,0" Height="34" Padding="14,0"
Content="Cancel" Content="Cancel"/>
AutomationProperties.Name="Cancel editing"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
<!-- Draft posted toast -->
<Border x:Name="DraftPostedToast"
Grid.Column="2"
Visibility="Collapsed"
VerticalAlignment="Bottom"
Margin="12" Padding="14,10"
CornerRadius="6"
Background="{DynamicResource MahApps.Brushes.Gray8}"
BorderThickness="0,0,0,3"
BorderBrush="{DynamicResource MahApps.Brushes.Accent}"
Panel.ZIndex="10">
<Border.RenderTransform>
<TranslateTransform x:Name="ToastTranslate" Y="60"/>
</Border.RenderTransform>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<iconPacks:PackIconMaterial Kind="CheckCircleOutline"
Width="16" Height="16" Margin="0,0,10,0"
Foreground="{DynamicResource MahApps.Brushes.Accent}"
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="x" Width="20" Height="20"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Click="DismissToast_Click" Margin="8,0,0,0"
AutomationProperties.Name="Dismiss notification"/>
</Grid>
</Border>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -4,7 +4,6 @@ using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Animation; using System.Windows.Media.Animation;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using System.Windows.Threading;
using EbayListingTool.Models; using EbayListingTool.Models;
using EbayListingTool.Services; using EbayListingTool.Services;
using Microsoft.Win32; using Microsoft.Win32;
@@ -15,8 +14,6 @@ public partial class SavedListingsView : UserControl
{ {
private SavedListingsService? _service; private SavedListingsService? _service;
private PriceLookupService? _priceLookup; private PriceLookupService? _priceLookup;
private EbayListingService? _ebayListing;
private EbayAuthService? _ebayAuth;
private SavedListing? _selected; private SavedListing? _selected;
// Edit mode working state // Edit mode working state
@@ -37,15 +34,10 @@ public partial class SavedListingsView : UserControl
}; };
} }
public void Initialise(SavedListingsService service, public void Initialise(SavedListingsService service, PriceLookupService? priceLookup = null)
PriceLookupService? priceLookup = null,
EbayListingService? ebayListing = null,
EbayAuthService? ebayAuth = null)
{ {
_service = service; _service = service;
_priceLookup = priceLookup; _priceLookup = priceLookup;
_ebayListing = ebayListing;
_ebayAuth = ebayAuth;
RefreshList(); RefreshList();
} }
@@ -197,20 +189,29 @@ public partial class SavedListingsView : UserControl
Margin = new Thickness(0, 0, 0, 3) Margin = new Thickness(0, 0, 0, 3)
}); });
var priceRow = new StackPanel { Orientation = Orientation.Horizontal }; // Verbal price — primary; digit price small beneath
priceRow.Children.Add(new TextBlock var priceVerbal = new TextBlock
{ {
Text = listing.PriceDisplay, Text = listing.PriceWords,
FontSize = 13, FontSize = 13,
FontWeight = FontWeights.Bold, FontWeight = FontWeights.Bold,
Foreground = (Brush)FindResource("MahApps.Brushes.Accent"), Foreground = (Brush)FindResource("MahApps.Brushes.Accent"),
Margin = new Thickness(0, 0, 8, 0) TextTrimming = TextTrimming.CharacterEllipsis
}); };
textStack.Children.Add(priceRow); textStack.Children.Add(priceVerbal);
textStack.Children.Add(new TextBlock textStack.Children.Add(new TextBlock
{ {
Text = listing.SavedAtDisplay, Text = listing.PriceDisplay,
FontSize = 9,
Opacity = 0.40,
Margin = new Thickness(0, 1, 0, 0)
});
// Relative date
textStack.Children.Add(new TextBlock
{
Text = listing.SavedAtRelative,
FontSize = 10, FontSize = 10,
Foreground = (Brush)FindResource("MahApps.Brushes.Gray5"), Foreground = (Brush)FindResource("MahApps.Brushes.Gray5"),
Margin = new Thickness(0, 3, 0, 0) Margin = new Thickness(0, 3, 0, 0)
@@ -272,7 +273,7 @@ public partial class SavedListingsView : UserControl
DetailTitle.Text = listing.Title; DetailTitle.Text = listing.Title;
DetailPrice.Text = listing.PriceDisplay; DetailPrice.Text = listing.PriceDisplay;
DetailCategory.Text = listing.Category; DetailCategory.Text = listing.Category;
DetailDate.Text = listing.SavedAtDisplay; DetailDate.Text = listing.SavedAtRelative;
DetailDescription.Text = listing.Description; DetailDescription.Text = listing.Description;
if (!string.IsNullOrWhiteSpace(listing.ConditionNotes)) if (!string.IsNullOrWhiteSpace(listing.ConditionNotes))
@@ -761,92 +762,4 @@ public partial class SavedListingsView : UserControl
ClearDetail(); ClearDetail();
RefreshList(); RefreshList();
} }
private async void PostDraft_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _ebayListing == null || _ebayAuth == null) return;
if (!_ebayAuth.IsConnected)
{
MessageBox.Show("Please connect to eBay first.", "Not Connected",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
SetPostDraftBusy(true);
try
{
var draft = _selected.ToListingDraft();
var url = await _ebayListing.PostListingAsync(draft);
var posted = _selected;
_selected = null;
_service?.Delete(posted);
ClearDetail();
RefreshList();
ToastUrlText.Text = url;
ShowDraftPostedToast();
}
catch (Exception ex)
{
MessageBox.Show($"Failed to post listing:\n\n{ex.Message}", "Post Failed",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally
{
SetPostDraftBusy(false);
}
}
private void SetPostDraftBusy(bool busy)
{
PostDraftBtn.IsEnabled = !busy;
PostDraftIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
PostDraftSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
IsEnabled = !busy;
}
private DispatcherTimer? _toastTimer;
private void ShowDraftPostedToast()
{
if (_toastTimer != null)
{
_toastTimer.Stop();
_toastTimer = null;
}
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, null);
DraftPostedToast.Visibility = Visibility.Visible;
var slideIn = new System.Windows.Media.Animation.DoubleAnimation(
60, 0, new Duration(TimeSpan.FromMilliseconds(220)))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
};
slideIn.Completed += (_, _) =>
{
_toastTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(8) };
_toastTimer.Tick += (_, _) => DismissToastAnimated();
_toastTimer.Start();
};
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideIn);
}
private void DismissToast_Click(object sender, RoutedEventArgs e)
=> DismissToastAnimated();
private void DismissToastAnimated()
{
_toastTimer?.Stop();
_toastTimer = null;
var slideOut = new System.Windows.Media.Animation.DoubleAnimation(
0, 60, new Duration(TimeSpan.FromMilliseconds(180)))
{
EasingFunction = new System.Windows.Media.Animation.CubicEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseIn }
};
slideOut.Completed += (_, _) => DraftPostedToast.Visibility = Visibility.Collapsed;
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideOut);
}
} }

View File

@@ -0,0 +1,566 @@
<UserControl x:Class="EbayListingTool.Views.SingleItemView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
Loaded="UserControl_Loaded">
<UserControl.Resources>
<!-- Section card — subtle bordered/shaded panel wrapping a group of fields -->
<Style x:Key="SectionCard" TargetType="Border">
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Padding" Value="14,12"/>
<Setter Property="Margin" Value="0,0,0,10"/>
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
</Style>
<!-- Upper-case accent section heading used inside each card -->
<Style x:Key="SectionHeading" TargetType="TextBlock">
<Setter Property="FontSize" Value="10"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Accent}"/>
<Setter Property="Margin" Value="0,0,0,0"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- Standard field label inside a card -->
<Style x:Key="FieldLabel" TargetType="TextBlock">
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Margin" Value="0,0,0,4"/>
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray2}"/>
</Style>
<!-- Small red asterisk for required fields -->
<Style x:Key="RequiredAsterisk" TargetType="TextBlock">
<Setter Property="Text" Value=" *"/>
<Setter Property="FontWeight" Value="Bold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="#E53935"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,0,0,4"/>
<Setter Property="ToolTip" Value="Required"/>
</Style>
<!-- AI buttons: indigo-to-violet gradient, icon + label -->
<Style x:Key="AiButton" TargetType="Button"
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
<Setter Property="Height" Value="28"/>
<Setter Property="Padding" Value="8,0"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#7C3AED" Offset="0"/>
<GradientStop Color="#4F46E5" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background">
<Setter.Value>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#8B5CF6" Offset="0"/>
<GradientStop Color="#6366F1" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Primary post/action button -->
<Style x:Key="PostButton" TargetType="Button"
BasedOn="{StaticResource MahApps.Styles.Button.Square.Accent}">
<Setter Property="Height" Value="36"/>
<Setter Property="Padding" Value="20,0"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Resources>
<Grid Margin="16,12,16,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="290"/>
</Grid.ColumnDefinitions>
<!-- ================================================================
LEFT COLUMN — form fields grouped into section cards
================================================================ -->
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- LISTING DETAILS -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<!-- Card header row -->
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<iconPacks:PackIconMaterial Kind="TagOutline" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock Text="LISTING DETAILS" Style="{StaticResource SectionHeading}"/>
</StackPanel>
<!-- Title label row with required asterisk -->
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource FieldLabel}" Text="Title"/>
<TextBlock Style="{StaticResource RequiredAsterisk}"/>
</StackPanel>
<!-- Title + AI Title button -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="TitleBox"
mah:TextBoxHelper.Watermark="Item title (max 80 characters)"
MaxLength="80"
TextChanged="TitleBox_TextChanged"/>
<Button Grid.Column="1" Margin="6,0,0,0"
Style="{StaticResource AiButton}"
Click="AiTitle_Click"
ToolTip="Ask Claude to write a keyword-rich eBay title based on your item details">
<StackPanel Orientation="Horizontal">
<mah:ProgressRing x:Name="TitleSpinner"
Width="11" Height="11" Margin="0,0,4,0"
Foreground="White" Visibility="Collapsed"/>
<iconPacks:PackIconMaterial x:Name="TitleAiIcon"
Kind="AutoFix" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="AI Title" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<!-- Visual character-count progress bar -->
<Grid Margin="0,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Track -->
<Border Grid.Column="0" Height="4" CornerRadius="2"
Background="{DynamicResource MahApps.Brushes.Gray8}"
VerticalAlignment="Center" Margin="0,0,8,0">
<!-- Fill — width set in code-behind via TitleCountBar.Width -->
<Border x:Name="TitleCountBar" HorizontalAlignment="Left"
Height="4" CornerRadius="2" Width="0"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Border>
<TextBlock x:Name="TitleCount" Grid.Column="1"
Text="0 / 80" FontSize="10"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"/>
</Grid>
<!-- Category label row with required asterisk -->
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<TextBlock Style="{StaticResource FieldLabel}" Text="Category" Margin="0,0,0,4"/>
<TextBlock Style="{StaticResource RequiredAsterisk}"/>
</StackPanel>
<!-- Category search -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="CategoryBox"
mah:TextBoxHelper.Watermark="Start typing to search categories..."
TextChanged="CategoryBox_TextChanged"
KeyDown="CategoryBox_KeyDown"/>
<Border Grid.Column="1" Margin="8,0,0,0" CornerRadius="3"
Padding="6,2" VerticalAlignment="Center"
Background="{DynamicResource MahApps.Brushes.Gray8}">
<TextBlock x:Name="CategoryIdLabel" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
Text="no category"/>
</Border>
</Grid>
<!-- Category dropdown suggestions -->
<ListBox x:Name="CategorySuggestionsList" MaxHeight="140"
SelectionChanged="CategorySuggestionsList_SelectionChanged"
Visibility="Collapsed" Margin="0,2,0,0"
Background="{DynamicResource MahApps.Brushes.Gray9}"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding CategoryPath}" FontSize="12" Padding="4,3"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Condition + Format -->
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Style="{StaticResource FieldLabel}" Text="Condition"/>
<ComboBox x:Name="ConditionBox"
SelectionChanged="ConditionBox_SelectionChanged">
<ComboBoxItem Content="New" Tag="New"
ToolTip="Brand new, unopened (eBay: New)"/>
<ComboBoxItem Content="Open Box" Tag="OpenBox"
ToolTip="Opened but unused (eBay: Open box)"/>
<ComboBoxItem Content="Refurbished" Tag="Refurbished"
ToolTip="Professionally restored (eBay: Seller refurbished)"/>
<ComboBoxItem Content="Used" Tag="Used" IsSelected="True"
ToolTip="Previously used, working (eBay: Used)"/>
<ComboBoxItem Content="For Parts / Not Working" Tag="ForParts"
ToolTip="Not fully working (eBay: For parts or not working)"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="Format"/>
<ComboBox x:Name="FormatBox">
<ComboBoxItem Content="Buy It Now" IsSelected="True"/>
<ComboBoxItem Content="Auction"/>
</ComboBox>
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- DESCRIPTION -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="TextBox" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock Text="DESCRIPTION" Style="{StaticResource SectionHeading}"/>
</StackPanel>
<Button Grid.Column="1" Style="{StaticResource AiButton}"
Click="AiDescription_Click"
ToolTip="Ask Claude to write a full product description">
<StackPanel Orientation="Horizontal">
<mah:ProgressRing x:Name="DescSpinner"
Width="11" Height="11" Margin="0,0,4,0"
Foreground="White" Visibility="Collapsed"/>
<iconPacks:PackIconMaterial x:Name="DescAiIcon"
Kind="AutoFix" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="AI Description" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<TextBox x:Name="DescriptionBox" Height="150" AcceptsReturn="True"
TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
mah:TextBoxHelper.Watermark="Describe the item, condition, what's included..."
Style="{DynamicResource MahApps.Styles.TextBox}"
TextChanged="DescriptionBox_TextChanged"/>
<!-- Description character count progress bar (soft limit 2000) -->
<Grid Margin="0,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Height="4" CornerRadius="2"
Background="{DynamicResource MahApps.Brushes.Gray8}"
VerticalAlignment="Center" Margin="0,0,8,0">
<Border x:Name="DescCountBar" HorizontalAlignment="Left"
Height="4" CornerRadius="2" Width="0"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Border>
<TextBlock x:Name="DescCount" Grid.Column="1"
Text="0 / 2000" FontSize="10"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"/>
</Grid>
</StackPanel>
</Border>
<!-- PRICING & LOGISTICS -->
<Border Style="{StaticResource SectionCard}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
<TextBlock Text="PRICING &amp; LOGISTICS" Style="{StaticResource SectionHeading}"/>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Price with inline AI button -->
<StackPanel Grid.Column="0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Style="{StaticResource FieldLabel}" Text="Price (£)"/>
<TextBlock Grid.Column="1" Style="{StaticResource RequiredAsterisk}"/>
<Button Grid.Column="3" Style="{StaticResource AiButton}"
Click="AiPrice_Click"
Height="22" Padding="6,0" FontSize="11"
Margin="4,0,0,4"
ToolTip="Ask Claude to suggest a competitive eBay UK price">
<StackPanel Orientation="Horizontal">
<mah:ProgressRing x:Name="PriceSpinner"
Width="9" Height="9" Margin="0,0,3,0"
Foreground="White" Visibility="Collapsed"/>
<iconPacks:PackIconMaterial x:Name="PriceAiIcon"
Kind="AutoFix" Width="10" Height="10"
Margin="0,0,3,0" VerticalAlignment="Center"/>
<TextBlock Text="AI Price" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<mah:NumericUpDown x:Name="PriceBox" Minimum="0" Maximum="99999"
StringFormat="F2" Interval="0.5" Value="0"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Style="{StaticResource FieldLabel}" Text="Quantity"/>
<mah:NumericUpDown x:Name="QuantityBox" Minimum="1" Maximum="999"
Value="1" StringFormat="0"/>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Style="{StaticResource FieldLabel}" Text="Postage"/>
<ComboBox x:Name="PostageBox">
<ComboBoxItem Content="Royal Mail 1st Class (~£1.55)" IsSelected="True"/>
<ComboBoxItem Content="Royal Mail 2nd Class (~£1.20)"/>
<ComboBoxItem Content="Royal Mail Tracked 24 (~£2.90)"/>
<ComboBoxItem Content="Royal Mail Tracked 48 (~£2.60)"/>
<ComboBoxItem Content="Free Postage"/>
<ComboBoxItem Content="Collection Only"/>
</ComboBox>
</StackPanel>
</Grid>
<!-- Postcode — narrower input, left-aligned -->
<StackPanel Margin="0,10,0,0">
<TextBlock Style="{StaticResource FieldLabel}" Text="Item Postcode"/>
<TextBox x:Name="PostcodeBox"
mah:TextBoxHelper.Watermark="e.g. NR1 1AA"
MaxLength="10" Width="150"
HorizontalAlignment="Left"/>
</StackPanel>
<!-- AI price suggestion result -->
<Border x:Name="PriceSuggestionPanel" Visibility="Collapsed"
CornerRadius="4" Margin="0,12,0,0" Padding="12,10"
Background="#1A7C3AED"
BorderBrush="#7C3AED" BorderThickness="1">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<iconPacks:PackIconMaterial Kind="AutoFix" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"
Foreground="#7C3AED"/>
<TextBlock Text="AI Price Suggestion" FontWeight="Bold"
FontSize="12" Foreground="#7C3AED"/>
</StackPanel>
<TextBlock x:Name="PriceSuggestionText" TextWrapping="Wrap"
FontSize="12" Margin="0,0,0,8"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
<Button Content="Use this price" HorizontalAlignment="Left"
Click="UseSuggestedPrice_Click"
Style="{StaticResource AiButton}"
Height="26" Padding="10,0" FontSize="11"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- ACTION BUTTONS -->
<StackPanel Orientation="Horizontal" Margin="0,2,0,8">
<!-- Post: primary accent + spinner overlay -->
<Button x:Name="PostBtn" Style="{StaticResource PostButton}"
Click="PostListing_Click">
<StackPanel Orientation="Horizontal">
<mah:ProgressRing x:Name="PostSpinner"
Width="14" Height="14" Margin="0,0,6,0"
Foreground="White" Visibility="Collapsed"/>
<iconPacks:PackIconMaterial x:Name="PostIcon"
Kind="Send" Width="14" Height="14"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button x:Name="SaveDraftBtn" Content="Save Draft"
Margin="8,0,0,0" Click="SaveDraft_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="36" Padding="14,0" FontSize="13"/>
<Button x:Name="NewListingBtn" Margin="8,0,0,0" Click="NewListing_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="36" Padding="14,0" FontSize="13">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Refresh" Width="13" Height="13"
Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="Clear" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
<!-- Posted success banner -->
<Border x:Name="SuccessPanel" Visibility="Collapsed"
CornerRadius="4" Padding="14,10"
Background="#1A4CAF50" BorderBrush="#4CAF50" BorderThickness="1">
<StackPanel>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CheckCircle" Width="16" Height="16"
Margin="0,0,8,0" VerticalAlignment="Center"
Foreground="#4CAF50"/>
<TextBlock Text="Posted! " FontWeight="Bold" VerticalAlignment="Center"
Foreground="#4CAF50"/>
<TextBlock x:Name="ListingUrlText" Foreground="{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

@@ -0,0 +1,694 @@
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.

Before

Width:  |  Height:  |  Size: 284 B