Compare commits
18 Commits
develop
...
feature/dy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9072a6018 | ||
|
|
61bbe86323 | ||
|
|
3e476cbdf1 | ||
|
|
4cf5f11c9f | ||
|
|
18bd3b910f | ||
|
|
40c62dbf34 | ||
|
|
8564c3d51c | ||
|
|
52f257b5ad | ||
|
|
e03fc0a49c | ||
|
|
d4dd11ed3e | ||
|
|
c9bdb6f7fe | ||
|
|
a22e11b2f7 | ||
|
|
f7b34b6a75 | ||
|
|
0ae47e9427 | ||
|
|
e70fb9ee5c | ||
|
|
af6f3f9234 | ||
|
|
a9cfb7f613 | ||
|
|
b3ef79e495 |
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
79
EbayListingTool/Helpers/NumberWords.cs
Normal file
79
EbayListingTool/Helpers/NumberWords.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,9 @@ public class BulkImportRow : INotifyPropertyChanged
|
|||||||
PhotoPaths = PhotoPaths.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
PhotoPaths = PhotoPaths.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(x => x.Trim())
|
.Select(x => x.Trim())
|
||||||
.Where(x => !string.IsNullOrEmpty(x))
|
.Where(x => !string.IsNullOrEmpty(x))
|
||||||
.ToList()
|
.ToList(),
|
||||||
|
Postage = PostageOption.RoyalMailTracked48,
|
||||||
|
ShippingCost = 3.49m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace EbayListingTool.Models;
|
namespace EbayListingTool.Models;
|
||||||
@@ -37,6 +37,8 @@ public class ListingDraft : INotifyPropertyChanged
|
|||||||
private ItemCondition _condition = ItemCondition.Used;
|
private ItemCondition _condition = ItemCondition.Used;
|
||||||
private ListingFormat _format = ListingFormat.FixedPrice;
|
private ListingFormat _format = ListingFormat.FixedPrice;
|
||||||
private PostageOption _postage = PostageOption.RoyalMailSecondClass;
|
private PostageOption _postage = PostageOption.RoyalMailSecondClass;
|
||||||
|
private Dictionary<string, string> _aspects = new();
|
||||||
|
private decimal _shippingCost;
|
||||||
private string _categoryId = "";
|
private string _categoryId = "";
|
||||||
private string _categoryName = "";
|
private string _categoryName = "";
|
||||||
private string _postcode = "";
|
private string _postcode = "";
|
||||||
@@ -92,6 +94,18 @@ public class ListingDraft : INotifyPropertyChanged
|
|||||||
set { _postage = value; OnPropertyChanged(); }
|
set { _postage = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, string> Aspects
|
||||||
|
{
|
||||||
|
get => _aspects;
|
||||||
|
set { _aspects = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal ShippingCost
|
||||||
|
{
|
||||||
|
get => _shippingCost;
|
||||||
|
set { _shippingCost = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
public string CategoryId
|
public string CategoryId
|
||||||
{
|
{
|
||||||
get => _categoryId;
|
get => _categoryId;
|
||||||
@@ -151,7 +165,7 @@ public class ListingDraft : INotifyPropertyChanged
|
|||||||
public string ConditionId => Condition switch
|
public string ConditionId => Condition switch
|
||||||
{
|
{
|
||||||
ItemCondition.New => "NEW",
|
ItemCondition.New => "NEW",
|
||||||
ItemCondition.OpenBox => "LIKE_NEW",
|
ItemCondition.OpenBox => "NEW_OTHER",
|
||||||
ItemCondition.Refurbished => "SELLER_REFURBISHED",
|
ItemCondition.Refurbished => "SELLER_REFURBISHED",
|
||||||
ItemCondition.Used => "USED_VERY_GOOD",
|
ItemCondition.Used => "USED_VERY_GOOD",
|
||||||
ItemCondition.ForPartsOrNotWorking => "FOR_PARTS_OR_NOT_WORKING",
|
ItemCondition.ForPartsOrNotWorking => "FOR_PARTS_OR_NOT_WORKING",
|
||||||
|
|||||||
74
EbayListingTool/Services/EbayAspectsService.cs
Normal file
74
EbayListingTool/Services/EbayAspectsService.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace EbayListingTool.Services;
|
||||||
|
|
||||||
|
public class CategoryAspect
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public bool IsRequired { get; set; }
|
||||||
|
public bool IsFreeText { get; set; } = true;
|
||||||
|
public List<string> AllowedValues { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EbayAspectsService
|
||||||
|
{
|
||||||
|
private readonly EbayAuthService _auth;
|
||||||
|
private static readonly HttpClient _http = new();
|
||||||
|
private readonly Dictionary<string, List<CategoryAspect>> _cache = new();
|
||||||
|
|
||||||
|
public EbayAspectsService(EbayAuthService auth) => _auth = auth;
|
||||||
|
|
||||||
|
public async Task<List<CategoryAspect>> GetAspectsAsync(string categoryId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(categoryId)) return new();
|
||||||
|
if (_cache.TryGetValue(categoryId, out var cached)) return cached;
|
||||||
|
|
||||||
|
var token = await _auth.GetAppTokenAsync();
|
||||||
|
var url = $"{_auth.BaseUrl}/commerce/taxonomy/v1/category_tree/3" +
|
||||||
|
$"/get_item_aspects_for_category?category_id={Uri.EscapeDataString(categoryId)}";
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
|
||||||
|
|
||||||
|
var res = await _http.SendAsync(req);
|
||||||
|
var json = await res.Content.ReadAsStringAsync();
|
||||||
|
if (!res.IsSuccessStatusCode) return new();
|
||||||
|
|
||||||
|
var aspects = new List<CategoryAspect>();
|
||||||
|
var arr = JObject.Parse(json)["aspects"] as JArray;
|
||||||
|
if (arr == null) { _cache[categoryId] = aspects; return aspects; }
|
||||||
|
|
||||||
|
foreach (var item in arr)
|
||||||
|
{
|
||||||
|
var constraint = item["aspectConstraint"];
|
||||||
|
if (constraint == null) continue;
|
||||||
|
|
||||||
|
var required = constraint["aspectRequired"]?.Value<bool>() ?? false;
|
||||||
|
var usage = constraint["aspectUsage"]?.ToString() ?? "";
|
||||||
|
if (!required && usage != "RECOMMENDED") continue;
|
||||||
|
|
||||||
|
var aspect = new CategoryAspect
|
||||||
|
{
|
||||||
|
Name = item["localizedAspectName"]?.ToString() ?? "",
|
||||||
|
IsRequired = required,
|
||||||
|
IsFreeText = constraint["aspectMode"]?.ToString() != "SELECTION_ONLY"
|
||||||
|
};
|
||||||
|
|
||||||
|
var values = item["aspectValues"] as JArray;
|
||||||
|
if (values != null)
|
||||||
|
aspect.AllowedValues = values
|
||||||
|
.Select(v => v["localizedValue"]?.ToString() ?? "")
|
||||||
|
.Where(v => !string.IsNullOrEmpty(v))
|
||||||
|
.Take(50)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(aspect.Name))
|
||||||
|
aspects.Add(aspect);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache[categoryId] = aspects;
|
||||||
|
return aspects;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,44 +16,67 @@ 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;
|
|
||||||
|
private readonly Dictionary<string, string> _policyCache = new();
|
||||||
|
private static readonly string PolicyCacheFile =
|
||||||
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"EbayListingTool", "fulfillment_policies.json");
|
||||||
|
|
||||||
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
|
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
|
||||||
{
|
{
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_categoryService = categoryService;
|
_categoryService = categoryService;
|
||||||
|
LoadPolicyCacheFromDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Call when the user disconnects so stale IDs are not reused after re-login.</summary>
|
/// <summary>Call when the user disconnects so stale IDs are not reused after re-login.</summary>
|
||||||
public void ClearCache()
|
public void ClearCache()
|
||||||
{
|
{
|
||||||
_fulfillmentPolicyId = null;
|
|
||||||
_paymentPolicyId = null;
|
_paymentPolicyId = null;
|
||||||
_returnPolicyId = null;
|
_returnPolicyId = null;
|
||||||
_merchantLocationKey = null;
|
_merchantLocationKey = null;
|
||||||
_triedPolicySetup = false;
|
}
|
||||||
|
|
||||||
|
private void LoadPolicyCacheFromDisk()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(PolicyCacheFile)) return;
|
||||||
|
var json = File.ReadAllText(PolicyCacheFile);
|
||||||
|
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
|
||||||
|
if (dict != null)
|
||||||
|
foreach (var kv in dict) _policyCache[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
catch { /* ignore corrupt cache */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SavePolicyCacheToDisk()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(PolicyCacheFile)!);
|
||||||
|
File.WriteAllText(PolicyCacheFile, JsonConvert.SerializeObject(_policyCache));
|
||||||
|
}
|
||||||
|
catch { /* non-critical */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
_fulfillmentPolicyId = await GetOrCreateFulfillmentPolicyAsync(draft.Postage, draft.ShippingCost, token);
|
||||||
|
|
||||||
|
// 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 +86,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;
|
||||||
@@ -74,52 +102,105 @@ public class EbayListingService
|
|||||||
return draft.EbayListingUrl;
|
return draft.EbayListingUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Fulfillment policy: on-demand creation ----
|
||||||
|
|
||||||
|
private static string ToShippingServiceCode(PostageOption option) => option switch
|
||||||
|
{
|
||||||
|
PostageOption.RoyalMailFirstClass => "UK_RoyalMailFirstClassStandard",
|
||||||
|
PostageOption.RoyalMailSecondClass => "UK_RoyalMailSecondClassStandard",
|
||||||
|
PostageOption.RoyalMailTracked24 => "UK_RoyalMailTracked24",
|
||||||
|
PostageOption.RoyalMailTracked48 => "UK_RoyalMailTracked48",
|
||||||
|
PostageOption.CollectionOnly => "UK_CollectInPerson",
|
||||||
|
PostageOption.FreePostage => "UK_RoyalMailSecondClassStandard",
|
||||||
|
_ => "UK_RoyalMailSecondClassStandard"
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<string> GetOrCreateFulfillmentPolicyAsync(
|
||||||
|
PostageOption postage, decimal shippingCost, string token)
|
||||||
|
{
|
||||||
|
var free = postage == PostageOption.FreePostage || postage == PostageOption.CollectionOnly;
|
||||||
|
var cost = free ? 0m : shippingCost;
|
||||||
|
var cacheKey = $"{postage}_{cost:F2}";
|
||||||
|
|
||||||
|
if (_policyCache.TryGetValue(cacheKey, out var cached)) return cached;
|
||||||
|
|
||||||
|
var serviceCode = ToShippingServiceCode(postage);
|
||||||
|
var policyName = $"ELT_{postage}_{cost:F2}".Replace(" ", "");
|
||||||
|
|
||||||
|
object shippingServiceObj;
|
||||||
|
if (postage == PostageOption.CollectionOnly)
|
||||||
|
{
|
||||||
|
shippingServiceObj = new
|
||||||
|
{
|
||||||
|
shippingServiceCode = serviceCode,
|
||||||
|
shippingCost = new { value = "0.00", currency = "GBP" },
|
||||||
|
freeShipping = false,
|
||||||
|
buyerResponsibleForShipping = true,
|
||||||
|
sortOrder = 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
shippingServiceObj = new
|
||||||
|
{
|
||||||
|
shippingCarrierCode = "RoyalMail",
|
||||||
|
shippingServiceCode = serviceCode,
|
||||||
|
shippingCost = new { value = cost.ToString("F2"), currency = "GBP" },
|
||||||
|
freeShipping = free,
|
||||||
|
sortOrder = 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
name = policyName,
|
||||||
|
marketplaceId = "EBAY_GB",
|
||||||
|
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
|
||||||
|
handlingTime = new { value = 2, unit = "DAY" },
|
||||||
|
shippingOptions = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
optionType = "DOMESTIC",
|
||||||
|
costType = postage == PostageOption.CollectionOnly ? "NOT_SPECIFIED" : "FLAT_RATE",
|
||||||
|
shippingServices = new[] { shippingServiceObj }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonConvert.SerializeObject(body,
|
||||||
|
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||||
|
|
||||||
|
using var req = MakeRequest(HttpMethod.Post,
|
||||||
|
$"{_auth.BaseUrl}/sell/account/v1/fulfillment_policy", token);
|
||||||
|
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
var res = await _http.SendAsync(req);
|
||||||
|
var resJson = await res.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!res.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Could not create fulfillment policy ({(int)res.StatusCode}): {resJson}");
|
||||||
|
|
||||||
|
var policyId = JObject.Parse(resJson)["fulfillmentPolicyId"]?.ToString()
|
||||||
|
?? throw new InvalidOperationException("No fulfillmentPolicyId in response.");
|
||||||
|
|
||||||
|
_policyCache[cacheKey] = policyId;
|
||||||
|
SavePolicyCacheToDisk();
|
||||||
|
return policyId;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 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;
|
||||||
|
|
||||||
if (_fulfillmentPolicyId == null)
|
|
||||||
{
|
|
||||||
using var req = MakeRequest(HttpMethod.Get,
|
|
||||||
$"{baseUrl}/sell/account/v1/fulfillment_policy?marketplace_id=EBAY_GB", token);
|
|
||||||
var res = await _http.SendAsync(req);
|
|
||||||
var json = await res.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
if (!res.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
if (!_triedPolicySetup && json.Contains("20403"))
|
|
||||||
{
|
|
||||||
_triedPolicySetup = true;
|
|
||||||
await SetupDefaultBusinessPoliciesAsync(token);
|
|
||||||
await EnsurePoliciesAndLocationAsync(token, postcode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new HttpRequestException(
|
|
||||||
$"Could not fetch fulfillment policies ({(int)res.StatusCode}): {json}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray;
|
|
||||||
_fulfillmentPolicyId = arr?.Count > 0
|
|
||||||
? arr[0]["fulfillmentPolicyId"]?.ToString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (_fulfillmentPolicyId == null)
|
|
||||||
{
|
|
||||||
if (!_triedPolicySetup)
|
|
||||||
{
|
|
||||||
_triedPolicySetup = true;
|
|
||||||
await SetupDefaultBusinessPoliciesAsync(token);
|
|
||||||
await EnsurePoliciesAndLocationAsync(token, postcode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"No fulfillment policy found on your eBay account.\n\n" +
|
|
||||||
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_paymentPolicyId == null)
|
if (_paymentPolicyId == null)
|
||||||
{
|
{
|
||||||
using var req = MakeRequest(HttpMethod.Get,
|
using var req = MakeRequest(HttpMethod.Get,
|
||||||
@@ -139,7 +220,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 +242,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 +260,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 +269,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,19 +309,28 @@ 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,
|
||||||
description = draft.Description,
|
description = draft.Description,
|
||||||
imageUrls = imageUrls.Count > 0 ? imageUrls : null,
|
imageUrls = imageUrls.Count > 0 ? imageUrls : null,
|
||||||
aspects = (object?)null
|
aspects = draft.Aspects.Count > 0
|
||||||
|
? draft.Aspects.ToDictionary(kv => kv.Key, kv => new[] { kv.Value })
|
||||||
|
: (object?)null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var json = JsonConvert.SerializeObject(inventoryItem, new JsonSerializerSettings
|
||||||
|
{
|
||||||
|
NullValueHandling = NullValueHandling.Ignore
|
||||||
|
});
|
||||||
|
|
||||||
var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
|
var 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 +359,23 @@ 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");
|
||||||
|
req.Content.Headers.Add("Content-Language", "en-US");
|
||||||
|
|
||||||
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 +409,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 +450,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 +474,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);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -142,19 +142,19 @@ public partial class NewListingView : UserControl
|
|||||||
var bmp = new BitmapImage();
|
var bmp = new BitmapImage();
|
||||||
bmp.BeginInit();
|
bmp.BeginInit();
|
||||||
bmp.UriSource = new Uri(path, UriKind.Absolute);
|
bmp.UriSource = new Uri(path, UriKind.Absolute);
|
||||||
bmp.DecodePixelWidth = 120;
|
bmp.DecodePixelWidth = 240;
|
||||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
bmp.EndInit();
|
bmp.EndInit();
|
||||||
bmp.Freeze();
|
bmp.Freeze();
|
||||||
|
|
||||||
var img = new Image
|
var img = new Image
|
||||||
{
|
{
|
||||||
Source = bmp, Width = 96, Height = 96,
|
Source = bmp, Width = 192, Height = 192,
|
||||||
Stretch = System.Windows.Media.Stretch.UniformToFill,
|
Stretch = System.Windows.Media.Stretch.UniformToFill,
|
||||||
Margin = new Thickness(4)
|
Margin = new Thickness(4)
|
||||||
};
|
};
|
||||||
img.Clip = new System.Windows.Media.RectangleGeometry(
|
img.Clip = new System.Windows.Media.RectangleGeometry(
|
||||||
new Rect(0, 0, 96, 96), 6, 6);
|
new Rect(0, 0, 192, 192), 6, 6);
|
||||||
ThumbStrip.Children.Add(img);
|
ThumbStrip.Children.Add(img);
|
||||||
}
|
}
|
||||||
catch { /* skip bad files */ }
|
catch { /* skip bad files */ }
|
||||||
@@ -488,18 +488,18 @@ public partial class NewListingView : UserControl
|
|||||||
var bmp = new BitmapImage();
|
var bmp = new BitmapImage();
|
||||||
bmp.BeginInit();
|
bmp.BeginInit();
|
||||||
bmp.UriSource = new Uri(path, UriKind.Absolute);
|
bmp.UriSource = new Uri(path, UriKind.Absolute);
|
||||||
bmp.DecodePixelWidth = 160;
|
bmp.DecodePixelWidth = 320;
|
||||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
bmp.EndInit();
|
bmp.EndInit();
|
||||||
bmp.Freeze();
|
bmp.Freeze();
|
||||||
|
|
||||||
var img = new Image
|
var img = new Image
|
||||||
{
|
{
|
||||||
Width = 100, Height = 100,
|
Width = 200, Height = 200,
|
||||||
Stretch = System.Windows.Media.Stretch.UniformToFill,
|
Stretch = System.Windows.Media.Stretch.UniformToFill,
|
||||||
Source = bmp, ToolTip = System.IO.Path.GetFileName(path)
|
Source = bmp, ToolTip = System.IO.Path.GetFileName(path)
|
||||||
};
|
};
|
||||||
img.Clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 100, 100), 6, 6);
|
img.Clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 200, 200), 6, 6);
|
||||||
|
|
||||||
var removeBtn = new Button
|
var removeBtn = new Button
|
||||||
{
|
{
|
||||||
@@ -544,7 +544,7 @@ public partial class NewListingView : UserControl
|
|||||||
|
|
||||||
var container = new Grid
|
var container = new Grid
|
||||||
{
|
{
|
||||||
Width = 100, Height = 100, Margin = new Thickness(4),
|
Width = 200, Height = 200, Margin = new Thickness(4),
|
||||||
Cursor = Cursors.SizeAll, AllowDrop = true, Tag = path
|
Cursor = Cursors.SizeAll, AllowDrop = true, Tag = path
|
||||||
};
|
};
|
||||||
container.Children.Add(img);
|
container.Children.Add(img);
|
||||||
|
|||||||
842
EbayListingTool/Views/PhotoAnalysisView.xaml
Normal file
842
EbayListingTool/Views/PhotoAnalysisView.xaml
Normal 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 & Price with AI"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
RIGHT: Results panel
|
||||||
|
================================================================ -->
|
||||||
|
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<!-- Idle state -->
|
||||||
|
<Border x:Name="IdlePanel" Style="{StaticResource SectionCard}"
|
||||||
|
MinHeight="400">
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||||
|
<iconPacks:PackIconMaterial Kind="TagOutline"
|
||||||
|
Width="52" Height="52"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
||||||
|
Margin="0,0,0,16"/>
|
||||||
|
<TextBlock Text="Drop a photo and click Identify"
|
||||||
|
FontSize="15" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
<TextBlock Text="Claude will identify the item, write a listing description and suggest a realistic eBay UK selling price."
|
||||||
|
FontSize="12" Margin="0,8,0,0"
|
||||||
|
HorizontalAlignment="Center" TextAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
||||||
|
TextWrapping="Wrap" MaxWidth="320"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Loading state (shown during analysis) -->
|
||||||
|
<Border x:Name="LoadingPanel" Style="{StaticResource SectionCard}"
|
||||||
|
MinHeight="400" Visibility="Collapsed">
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||||
|
<mah:ProgressRing Width="48" Height="48"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,0,0,20"/>
|
||||||
|
<TextBlock x:Name="LoadingStepText"
|
||||||
|
Text="Examining the photo…"
|
||||||
|
FontSize="15" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||||
|
<TextBlock Text="This usually takes 10–20 seconds"
|
||||||
|
FontSize="11" Margin="0,8,0,0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
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>
|
||||||
834
EbayListingTool/Views/PhotoAnalysisView.xaml.cs
Normal file
834
EbayListingTool/Views/PhotoAnalysisView.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
625
EbayListingTool/Views/SingleItemView.xaml
Normal file
625
EbayListingTool/Views/SingleItemView.xaml
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Item Specifics (Aspects) panel — revealed after category is selected -->
|
||||||
|
<Border x:Name="AspectsPanel" Visibility="Collapsed"
|
||||||
|
CornerRadius="4" Margin="0,10,0,0" Padding="12,10"
|
||||||
|
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}" BorderThickness="1">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<mah:ProgressRing x:Name="AspectsSpinner" Width="14" Height="14"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
<TextBlock Text="Item Specifics" FontWeight="SemiBold" FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
|
||||||
|
<TextBlock x:Name="AspectsRequiredNote"
|
||||||
|
Text=" (required fields marked *)"
|
||||||
|
FontSize="11" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" x:Name="AiAspectsBtn"
|
||||||
|
Style="{StaticResource AiButton}"
|
||||||
|
Height="26" Padding="10,0" FontSize="11"
|
||||||
|
Click="AiAspects_Click"
|
||||||
|
ToolTip="Let AI suggest values from your title and description">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<mah:ProgressRing x:Name="AspectsAiSpinner" Width="11" Height="11"
|
||||||
|
Margin="0,0,4,0" Foreground="White"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
<iconPacks:PackIconMaterial x:Name="AspectsAiIcon" Kind="AutoFix"
|
||||||
|
Width="11" Height="11" Margin="0,0,4,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="AI Suggest" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<ItemsControl x:Name="AspectsItemsControl">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Condition + Format -->
|
||||||
|
<Grid Margin="0,10,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="12"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock Style="{StaticResource FieldLabel}" Text="Condition"/>
|
||||||
|
<ComboBox x:Name="ConditionBox"
|
||||||
|
SelectionChanged="ConditionBox_SelectionChanged">
|
||||||
|
<ComboBoxItem Content="New" Tag="New"
|
||||||
|
ToolTip="Brand new, unopened (eBay: New)"/>
|
||||||
|
<ComboBoxItem Content="Open Box" Tag="OpenBox"
|
||||||
|
ToolTip="Opened but unused (eBay: Open box)"/>
|
||||||
|
<ComboBoxItem Content="Refurbished" Tag="Refurbished"
|
||||||
|
ToolTip="Professionally restored (eBay: Seller refurbished)"/>
|
||||||
|
<ComboBoxItem Content="Used" Tag="Used" IsSelected="True"
|
||||||
|
ToolTip="Previously used, working (eBay: Used)"/>
|
||||||
|
<ComboBoxItem Content="For Parts / Not Working" Tag="ForParts"
|
||||||
|
ToolTip="Not fully working (eBay: For parts or not working)"/>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2">
|
||||||
|
<TextBlock Style="{StaticResource FieldLabel}" Text="Format"/>
|
||||||
|
<ComboBox x:Name="FormatBox">
|
||||||
|
<ComboBoxItem Content="Buy It Now" IsSelected="True"/>
|
||||||
|
<ComboBoxItem Content="Auction"/>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- DESCRIPTION -->
|
||||||
|
<Border Style="{StaticResource SectionCard}">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial Kind="TextBox" Width="13" Height="13"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||||
|
<TextBlock Text="DESCRIPTION" Style="{StaticResource SectionHeading}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Style="{StaticResource AiButton}"
|
||||||
|
Click="AiDescription_Click"
|
||||||
|
ToolTip="Ask Claude to write a full product description">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<mah:ProgressRing x:Name="DescSpinner"
|
||||||
|
Width="11" Height="11" Margin="0,0,4,0"
|
||||||
|
Foreground="White" Visibility="Collapsed"/>
|
||||||
|
<iconPacks:PackIconMaterial x:Name="DescAiIcon"
|
||||||
|
Kind="AutoFix" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="AI Description" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<TextBox x:Name="DescriptionBox" Height="150" AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
|
||||||
|
mah:TextBoxHelper.Watermark="Describe the item, condition, what's included..."
|
||||||
|
Style="{DynamicResource MahApps.Styles.TextBox}"
|
||||||
|
TextChanged="DescriptionBox_TextChanged"/>
|
||||||
|
|
||||||
|
<!-- Description character count progress bar (soft limit 2000) -->
|
||||||
|
<Grid Margin="0,5,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Grid.Column="0" Height="4" CornerRadius="2"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,8,0">
|
||||||
|
<Border x:Name="DescCountBar" HorizontalAlignment="Left"
|
||||||
|
Height="4" CornerRadius="2" Width="0"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock x:Name="DescCount" Grid.Column="1"
|
||||||
|
Text="0 / 2000" FontSize="10"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- PRICING & LOGISTICS -->
|
||||||
|
<Border Style="{StaticResource SectionCard}">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||||
|
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="13" Height="13"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||||
|
<TextBlock Text="PRICING & LOGISTICS" Style="{StaticResource SectionHeading}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="12"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="12"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="16"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</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" SelectionChanged="PostageBox_SelectionChanged">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="6">
|
||||||
|
<TextBlock Style="{StaticResource FieldLabel}" Text="Shipping Cost (£)"/>
|
||||||
|
<mah:NumericUpDown x:Name="ShippingCostBox"
|
||||||
|
Minimum="0" Maximum="99" StringFormat="F2"
|
||||||
|
Interval="0.50" Value="0"
|
||||||
|
Width="110" HorizontalAlignment="Left"/>
|
||||||
|
</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 & 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>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 284 B |
Reference in New Issue
Block a user