13 Commits

Author SHA1 Message Date
Peter Foster
edbce97a74 Add .gitattributes to normalise line endings
Fixes Windows git showing modified files after Linux commits.
LF stored in repo; CRLF on Windows checkout for all source files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 02:45:17 +01:00
Peter Foster
f65521b9ab Add layered price lookup and category auto-fill
- PriceLookupService: eBay live data → saved listing history → AI estimate,
  each result labelled by source so the user knows how reliable it is
- Revalue row: new "Check eBay" button fetches suggestion and pre-populates
  the price field; shows source label beneath (or "No suggestion available")
- Category auto-fill: AutoFillCategoryAsync takes the top eBay category
  suggestion and fills the field automatically after photo analysis or AI
  title generation; dropdown stays visible so user can override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 02:44:12 +01:00
Peter Foster
da0efc1374 Replace IE WebBrowser with WebView2 for eBay OAuth login
- Add Microsoft.Web.WebView2 package
- New EbayLoginWindow using Edge (WebView2) instead of IE — handles modern eBay login pages
- Intercept ThirdPartyAuthSucessFailure redirect to extract auth code
- Remove invalid sell.listing scope that caused immediate invalid_scope error
- Add detailed browser navigation logging for diagnostics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 17:06:39 +01:00
Peter Foster
1ff9d3d78b Add eBay credentials, edit listings feature, fix category service token 2026-04-14 11:45:15 +01:00
Peter Foster
f4e7854297 Revert to Light.Indigo theme; restore original grey values for light bg
Dark theme experiment abandoned due to persistent contrast issues.
All Gray3/Gray4 muted text reverted to Gray5/Gray6 (darker = readable
on light background). Gray2 headings reverted to Gray4/Gray5.
App.xaml switched back to Light.Indigo.xaml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 03:21:10 +01:00
Peter Foster
56e0be83d2 Fix low-contrast price and date text in saved listings cards
Price text changed from Accent brush (~3.4:1) to ThemeForeground (white)
for WCAG AA compliance. Date text changed from Gray5 (~3.2:1) to Gray3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 03:13:41 +01:00
Peter Foster
5cf7f1b8c6 Switch theme to Dark.Cobalt for readable accent colour
Indigo 500 (#3F51B5) is too dark on black backgrounds. Cobalt (#0050EF)
is a bright electric blue that meets contrast requirements on dark
surfaces without needing lighter fallback shades.

Replaced all hardcoded #9FA8DA (Indigo 200 workaround) with
{DynamicResource MahApps.Brushes.Accent} now that the accent is
naturally readable — section headings, price labels, detail labels,
listing URL link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 03:05:06 +01:00
Peter Foster
15726a4f18 Fix dark theme contrast across all views
All text using Gray5/Gray6 (too dark — fails WCAG AA) promoted to
Gray3/Gray4 as appropriate. SectionHeading styles using Accent brush
(Indigo 500, ~2.8:1 on dark bg) changed to #9FA8DA (Indigo 200, ~7:1).

MainWindow: overlay body text Gray5→Gray3, status bar text Gray5→Gray3.
PhotoAnalysisView: SectionHeading Accent→#9FA8DA; 13 text nodes updated
  from Gray5/Gray6 to Gray3/Gray4 (hints, labels, counters, live price
  status, price reasoning, range bar labels).
SingleItemView: SectionHeading Accent→#9FA8DA; title/desc counters,
  photo drop hint, listing URL all updated.
BulkImportView: empty state heading Gray4→Gray2; hint text Gray6→Gray4.
SavedListingsView: DetailLabel Accent→#9FA8DA; search icon, empty state
  headings, meta row text/icons all promoted from Gray4-6 to Gray2-4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 03:02:05 +01:00
Peter Foster
bd59db724a Apply dark theme (Dark.Indigo) across all views
Switch MahApps.Metro base theme from Light.Indigo to Dark.Indigo in
App.xaml. Fix hardcoded light-specific colours in all five views:

- MainWindow: overlay backgrounds from hardcoded light-blue gradients
  to ThemeBackground; overlay text updated to ThemeForeground / Gray5
- SingleItemView: listing URL colour from hardcoded #1565C0 (invisible
  on dark) to Accent brush
- PhotoAnalysisView / BulkImportView / SavedListingsView: dynamic
  resource brushes already used; no structural changes needed

Semantic colours (green/red/amber indicators, AI gradient buttons,
white text on coloured backgrounds) left unchanged as they read
correctly on dark surfaces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:54:01 +01:00
Peter Foster
48c6049dfc Track appsettings.json in git; remove from gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:48:56 +01:00
Peter Foster
ffba3ce1b6 Make appsettings.json optional; support appsettings.local.json override
App no longer crashes on startup when appsettings.json is absent (e.g.
fresh clone). Configuration is loaded from appsettings.json if present,
then overridden by appsettings.local.json if that exists. Both files
are gitignored to keep secrets out of the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:48:18 +01:00
Peter Foster
d3bdcc1061 Fix all listing blockers: real policy IDs, merchant location, shared HttpClient
Business policies: EnsurePoliciesAndLocationAsync fetches fulfillment,
payment and return policy IDs from the seller's eBay Account API on
first post and caches them for the session. Uses the first policy of
each type; gives a clear error pointing to My eBay → Business policies
if none are configured.

Merchant location: checks for existing locations via
GET /sell/inventory/v1/location; if none found, creates a 'home'
location using the seller's postcode. Location key is cached so the
check only runs once. Cache cleared on disconnect so it works
correctly after switching accounts.

CreateOfferAsync now sends real fulfillmentPolicyId / paymentPolicyId /
returnPolicyId instead of hardcoded policy name strings, and uses the
resolved merchantLocationKey instead of the hardcoded "home" string.

Removed BuildListingPolicies (inline shipping service codes no longer
needed; shipping is governed by the fulfillment policy).

Shared HttpClient: replaced BuildClient() (which returned a new
HttpClient per call) with a static _http client and MakeRequest()
helper that creates a pre-authorised HttpRequestMessage. UploadSinglePhotoAsync
likewise uses a static _photoHttp client and HttpRequestMessage instead
of new HttpClient() per photo.

Removed placeholder ExternalPictureURL from Trading API SOAP body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:24:06 +01:00
Peter Foster
6efa5df2c6 Add drag-to-reorder photo ordering in New Listing tab
Photos can now be dragged to any position — cursor changes to a move
cursor to signal draggability, the drop target dims to show the
insertion point, and the list rebuilds immediately after the drop.

First photo gets a 'Cover' badge since eBay uses it as the gallery
hero image. All add/remove/clear operations now go through
RebuildPhotoThumbnails() so the panel always reflects the true order
in PhotoPaths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:52:37 +01:00
19 changed files with 1265 additions and 213 deletions

19
.gitattributes vendored Normal file
View File

@@ -0,0 +1,19 @@
# Normalise line endings: LF in repo, native on checkout
* text=auto
# Force CRLF on checkout for Windows source files
*.cs text eol=crlf
*.xaml text eol=crlf
*.csproj text eol=crlf
*.sln text eol=crlf
*.json text eol=crlf
*.txt text eol=crlf
*.md text eol=crlf
*.csv text eol=crlf
# Binaries — no line-ending conversion
*.png binary
*.jpg binary
*.ico binary
*.dll binary
*.exe binary

2
.gitignore vendored
View File

@@ -5,8 +5,6 @@ obj/
*.suo
.vs/
# Config with secrets — never commit
EbayListingTool/appsettings.json
# Rider / JetBrains
.idea/

View File

@@ -22,7 +22,8 @@ public partial class App : Application
Configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: false)
.Build();
base.OnStartup(e);

View File

@@ -24,6 +24,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2651.64" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,8 +1,8 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Windows;
using EbayListingTool.Models;
using EbayListingTool.Views;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -27,7 +27,6 @@ public class EbayAuthService
[
"https://api.ebay.com/oauth/api_scope",
"https://api.ebay.com/oauth/api_scope/sell.inventory",
"https://api.ebay.com/oauth/api_scope/sell.listing",
"https://api.ebay.com/oauth/api_scope/sell.fulfillment",
"https://api.ebay.com/oauth/api_scope/sell.account"
];
@@ -69,7 +68,6 @@ public class EbayAuthService
public async Task<string> LoginAsync()
{
var redirectUri = $"http://localhost:{_settings.RedirectPort}/";
var scopeString = Uri.EscapeDataString(string.Join(" ", Scopes));
var authBase = _settings.Sandbox
? "https://auth.sandbox.ebay.com/oauth2/authorize"
@@ -79,39 +77,39 @@ public class EbayAuthService
$"&redirect_uri={Uri.EscapeDataString(_settings.RuName)}" +
$"&response_type=code&scope={scopeString}";
// Start local listener before opening browser
using var listener = new HttpListener();
listener.Prefixes.Add(redirectUri);
listener.Start();
Log($"LoginAsync start — RuName={_settings.RuName}, ClientId={_settings.ClientId}");
// Open browser
Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });
string? code = null;
// Wait for redirect with code (60s timeout)
var contextTask = listener.GetContextAsync();
if (await Task.WhenAny(contextTask, Task.Delay(TimeSpan.FromSeconds(60))) != contextTask)
// Open embedded browser dialog on UI thread and block until it closes
await Application.Current.Dispatcher.InvokeAsync(() =>
{
listener.Stop();
throw new TimeoutException("eBay login timed out. Please try again.");
var win = new EbayLoginWindow(authUrl);
var result = win.ShowDialog();
if (result == true)
code = win.AuthCode;
});
if (string.IsNullOrEmpty(code))
{
Log("User cancelled login or no code returned");
throw new InvalidOperationException("eBay login was cancelled or did not return an authorisation code.");
}
var context = await contextTask;
var code = context.Request.QueryString["code"]
?? throw new InvalidOperationException("No authorisation code received from eBay.");
// Send OK page to browser
var responseHtml = "<html><body><h2>Connected! You can close this tab.</h2></body></html>";
var responseBytes = Encoding.UTF8.GetBytes(responseHtml);
context.Response.ContentType = "text/html";
context.Response.ContentLength64 = responseBytes.Length;
await context.Response.OutputStream.WriteAsync(responseBytes);
context.Response.Close();
listener.Stop();
Log($"Auth code received (length={code.Length})");
await ExchangeCodeForTokenAsync(code);
return _token!.EbayUsername;
}
private static readonly string LogFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EbayListingTool", "auth_log.txt");
private static void Log(string msg)
{
try { File.AppendAllText(LogFile, $"{DateTime.Now:HH:mm:ss} {msg}\n"); } catch { }
}
private async Task ExchangeCodeForTokenAsync(string code)
{
var tokenUrl = _settings.Sandbox
@@ -130,20 +128,33 @@ public class EbayAuthService
["redirect_uri"] = _settings.RuName
});
Log($"Token exchange → POST {tokenUrl}");
var response = await _http.SendAsync(codeRequest);
var json = await response.Content.ReadAsStringAsync();
Log($"Token exchange ← {(int)response.StatusCode}: {json}");
System.Diagnostics.Debug.WriteLine($"[eBay token exchange] {(int)response.StatusCode}: {json}");
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Token exchange failed: {json}");
throw new HttpRequestException($"Token exchange failed ({(int)response.StatusCode}): {json}");
var obj = JObject.Parse(json);
var accessToken = obj["access_token"]?.ToString()
?? throw new InvalidOperationException($"No access_token in response: {json}");
var expiresIn = obj["expires_in"]?.Value<int>() ?? 7200;
var refreshToken = obj["refresh_token"]?.ToString() ?? "";
var refreshExpiresIn = obj["refresh_token_expires_in"]?.Value<int>() ?? 0;
_token = new EbayToken
{
AccessToken = obj["access_token"]!.ToString(),
RefreshToken = obj["refresh_token"]!.ToString(),
AccessTokenExpiry = DateTime.UtcNow.AddSeconds(obj["expires_in"]!.Value<int>()),
RefreshTokenExpiry = DateTime.UtcNow.AddSeconds(obj["refresh_token_expires_in"]!.Value<int>()),
AccessToken = accessToken,
RefreshToken = refreshToken,
AccessTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn),
RefreshTokenExpiry = refreshExpiresIn > 0
? DateTime.UtcNow.AddSeconds(refreshExpiresIn)
: DateTime.UtcNow.AddDays(18 * 30), // eBay default: 18 months
};
Log($"Token set — AccessToken length={accessToken.Length}, Expiry={_token.AccessTokenExpiry:HH:mm:ss}, IsValid={_token.IsAccessTokenValid}");
// Fetch username
_token.EbayUsername = await FetchUsernameAsync(_token.AccessToken);

View File

@@ -14,6 +14,9 @@ public class EbayCategoryService
{
private readonly EbayAuthService _auth;
// Static client — avoids socket exhaustion from per-call `new HttpClient()`
private static readonly HttpClient _http = new();
public EbayCategoryService(EbayAuthService auth)
{
_auth = auth;
@@ -26,15 +29,18 @@ public class EbayCategoryService
try
{
var token = await _auth.GetValidAccessTokenAsync();
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
// Taxonomy API supports app-level tokens — no user login required
var token = await _auth.GetAppTokenAsync();
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{_auth.BaseUrl}/commerce/taxonomy/v1/category_tree/3/get_category_suggestions" +
$"?q={Uri.EscapeDataString(query)}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
var url = $"{_auth.BaseUrl}/commerce/taxonomy/v1/category_tree/3/get_category_suggestions" +
$"?q={Uri.EscapeDataString(query)}";
var response = await _http.SendAsync(request);
var json = await response.Content.ReadAsStringAsync();
var json = await http.GetStringAsync(url);
if (!response.IsSuccessStatusCode) return new List<CategorySuggestion>();
var obj = JObject.Parse(json);
var results = new List<CategorySuggestion>();

View File

@@ -12,17 +12,39 @@ public class EbayListingService
private readonly EbayAuthService _auth;
private readonly EbayCategoryService _categoryService;
// Shared clients — avoids socket exhaustion from per-call `new HttpClient()`
private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs
private static readonly HttpClient _photoHttp = new(); // Trading API (photo upload)
// Per-session cache of eBay account IDs — fetched once, reused for every listing
private string? _fulfillmentPolicyId;
private string? _paymentPolicyId;
private string? _returnPolicyId;
private string? _merchantLocationKey;
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
{
_auth = auth;
_categoryService = categoryService;
}
/// <summary>Call when the user disconnects so stale IDs are not reused after re-login.</summary>
public void ClearCache()
{
_fulfillmentPolicyId = null;
_paymentPolicyId = null;
_returnPolicyId = null;
_merchantLocationKey = null;
}
public async Task<string> PostListingAsync(ListingDraft draft)
{
var token = await _auth.GetValidAccessTokenAsync();
// 1. Upload photos and get URLs
// Resolve business policies and merchant location before touching inventory/offers
await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
// 1. Upload photos and get eBay-hosted URLs
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
// 2. Resolve category if not set
@@ -51,23 +73,148 @@ public class EbayListingService
return draft.EbayListingUrl;
}
// ---- Setup: policies + location ----
/// <summary>
/// Fetches fulfillment, payment and return policy IDs from the seller's eBay account,
/// and ensures at least one merchant location exists (creating "home" from the seller's
/// postcode if needed). Results are cached for the session.
/// </summary>
private async Task EnsurePoliciesAndLocationAsync(string token, string postcode)
{
var baseUrl = _auth.BaseUrl;
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)
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)
throw new InvalidOperationException(
"No fulfillment policy found on your eBay account.\n\n" +
"Please set one up in My eBay → Account → Business policies, then try again.");
}
if (_paymentPolicyId == null)
{
using var req = MakeRequest(HttpMethod.Get,
$"{baseUrl}/sell/account/v1/payment_policy?marketplace_id=EBAY_GB", token);
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException(
$"Could not fetch payment policies ({(int)res.StatusCode}): {json}");
var arr = JObject.Parse(json)["paymentPolicies"] as JArray;
_paymentPolicyId = arr?.Count > 0
? arr[0]["paymentPolicyId"]?.ToString()
: null;
if (_paymentPolicyId == null)
throw new InvalidOperationException(
"No payment policy found on your eBay account.\n\n" +
"Please set one up in My eBay → Account → Business policies, then try again.");
}
if (_returnPolicyId == null)
{
using var req = MakeRequest(HttpMethod.Get,
$"{baseUrl}/sell/account/v1/return_policy?marketplace_id=EBAY_GB", token);
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException(
$"Could not fetch return policies ({(int)res.StatusCode}): {json}");
var arr = JObject.Parse(json)["returnPolicies"] as JArray;
_returnPolicyId = arr?.Count > 0
? arr[0]["returnPolicyId"]?.ToString()
: null;
if (_returnPolicyId == null)
throw new InvalidOperationException(
"No return policy found on your eBay account.\n\n" +
"Please set one up in My eBay → Account → Business policies, then try again.");
}
if (_merchantLocationKey == null)
{
using var req = MakeRequest(HttpMethod.Get,
$"{baseUrl}/sell/inventory/v1/location", token);
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (res.IsSuccessStatusCode)
{
var arr = JObject.Parse(json)["locations"] as JArray;
_merchantLocationKey = arr?.Count > 0
? arr[0]["merchantLocationKey"]?.ToString()
: null;
}
// No existing locations — create one from the seller's postcode
if (_merchantLocationKey == null)
{
await CreateMerchantLocationAsync(token, postcode);
_merchantLocationKey = "home";
}
}
}
private async Task CreateMerchantLocationAsync(string token, string postcode)
{
if (string.IsNullOrWhiteSpace(postcode))
postcode = "N/A"; // eBay allows this when postcode is genuinely unknown
var body = new
{
location = new
{
address = new { postalCode = postcode, country = "GB" }
},
locationTypes = new[] { "WAREHOUSE" },
name = "Home",
merchantLocationStatus = "ENABLED"
};
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/inventory/v1/location/home", token);
req.Content = new StringContent(
JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException(
$"Could not create merchant location ({(int)res.StatusCode}): {json}");
}
// ---- Inventory item ----
private async Task CreateInventoryItemAsync(ListingDraft draft, List<string> imageUrls, string token)
{
using var http = BuildClient(token);
var aspects = new Dictionary<string, List<string>>();
var inventoryItem = new
{
availability = new
{
shipToLocationAvailability = new
{
quantity = draft.Quantity
}
shipToLocationAvailability = new { quantity = draft.Quantity }
},
condition = draft.ConditionId,
conditionDescription = draft.Condition == ItemCondition.Used ? "Used - see photos" : null,
conditionDescription = draft.Condition == ItemCondition.Used ? "Used see photos" : null,
description = draft.Description,
title = draft.Title,
product = new
@@ -75,7 +222,7 @@ public class EbayListingService
title = draft.Title,
description = draft.Description,
imageUrls = imageUrls.Count > 0 ? imageUrls : null,
aspects = aspects.Count > 0 ? aspects : null
aspects = (object?)null
}
};
@@ -85,26 +232,22 @@ public class EbayListingService
});
var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
var request = new HttpRequestMessage(HttpMethod.Put, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Content.Headers.Add("Content-Language", "en-GB");
using var req = MakeRequest(HttpMethod.Put, url, token);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Content.Headers.Add("Content-Language", "en-GB");
var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode)
var res = await _http.SendAsync(req);
if (!res.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Failed to create inventory item: {error}");
var err = await res.Content.ReadAsStringAsync();
throw new HttpRequestException($"Failed to create inventory item: {err}");
}
}
// ---- Offer ----
private async Task<string> CreateOfferAsync(ListingDraft draft, string token)
{
using var http = BuildClient(token);
var listingPolicies = BuildListingPolicies(draft);
var offer = new
{
sku = draft.Sku,
@@ -113,12 +256,17 @@ public class EbayListingService
availableQuantity = draft.Quantity,
categoryId = draft.CategoryId,
listingDescription = draft.Description,
listingPolicies,
listingPolicies = new
{
fulfillmentPolicyId = _fulfillmentPolicyId,
paymentPolicyId = _paymentPolicyId,
returnPolicyId = _returnPolicyId
},
pricingSummary = new
{
price = new { value = draft.Price.ToString("F2"), currency = "GBP" }
},
merchantLocationKey = "home",
merchantLocationKey = _merchantLocationKey,
tax = new { vatPercentage = 0, applyTax = false }
};
@@ -127,39 +275,45 @@ public class EbayListingService
NullValueHandling = NullValueHandling.Ignore
});
var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer";
var response = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json"));
var responseJson = await response.Content.ReadAsStringAsync();
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/inventory/v1/offer", token);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
if (!response.IsSuccessStatusCode)
var res = await _http.SendAsync(req);
var responseJson = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"Failed to create offer: {responseJson}");
var obj = JObject.Parse(responseJson);
return obj["offerId"]?.ToString()
return JObject.Parse(responseJson)["offerId"]?.ToString()
?? throw new InvalidOperationException("No offerId in create offer response.");
}
// ---- Publish ----
private async Task<string> PublishOfferAsync(string offerId, string token)
{
using var http = BuildClient(token);
var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish";
var response = await http.PostAsync(url, new StringContent("{}", Encoding.UTF8, "application/json"));
var responseJson = await response.Content.ReadAsStringAsync();
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish", token);
req.Content = new StringContent("{}", Encoding.UTF8, "application/json");
if (!response.IsSuccessStatusCode)
var res = await _http.SendAsync(req);
var responseJson = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"Failed to publish offer: {responseJson}");
var obj = JObject.Parse(responseJson);
return obj["listingId"]?.ToString()
return JObject.Parse(responseJson)["listingId"]?.ToString()
?? throw new InvalidOperationException("No listingId in publish response.");
}
// ---- Photo upload ----
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
{
var urls = new List<string>();
if (photoPaths.Count == 0) return urls;
// Use Trading API UploadSiteHostedPictures for each photo
var tradingBase = _auth.BaseUrl.Contains("sandbox")
? "https://api.sandbox.ebay.com/ws/api.dll"
: "https://api.ebay.com/ws/api.dll";
@@ -167,7 +321,6 @@ public class EbayListingService
foreach (var path in photoPaths.Take(12))
{
if (!File.Exists(path)) continue;
try
{
var url = await UploadSinglePhotoAsync(path, tradingBase, token);
@@ -176,7 +329,7 @@ public class EbayListingService
}
catch
{
// Skip failed photo uploads, don't abort the whole listing
// Skip failed photos; don't abort the whole listing
}
}
@@ -186,8 +339,7 @@ public class EbayListingService
private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
{
var fileBytes = await File.ReadAllBytesAsync(filePath);
var base64 = Convert.ToBase64String(fileBytes);
var ext = Path.GetExtension(filePath).TrimStart('.').ToUpper();
var ext = Path.GetExtension(filePath).TrimStart('.').ToLower();
var soapBody = $"""
<?xml version="1.0" encoding="utf-8"?>
@@ -197,78 +349,39 @@ public class EbayListingService
</RequesterCredentials>
<PictureName>{Path.GetFileNameWithoutExtension(filePath)}</PictureName>
<PictureSet>Supersize</PictureSet>
<ExternalPictureURL>https://example.com/placeholder.jpg</ExternalPictureURL>
</UploadSiteHostedPicturesRequest>
""";
// For binary upload, use multipart
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-EBAY-API-SITEID", "3");
http.DefaultRequestHeaders.Add("X-EBAY-API-COMPATIBILITY-LEVEL", "967");
http.DefaultRequestHeaders.Add("X-EBAY-API-CALL-NAME", "UploadSiteHostedPictures");
http.DefaultRequestHeaders.Add("X-EBAY-API-IAF-TOKEN", token);
// Use HttpRequestMessage with _photoHttp so we don't create a new socket per photo
using var content = new MultipartFormDataContent();
content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
var imageContent = new ByteArrayContent(fileBytes);
imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext.ToLower()}");
content.Add(imageContent, "dummy", Path.GetFileName(filePath));
imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext}");
content.Add(imageContent, "image", Path.GetFileName(filePath));
var response = await http.PostAsync(tradingUrl, content);
using var req = new HttpRequestMessage(HttpMethod.Post, tradingUrl);
req.Headers.Add("X-EBAY-API-SITEID", "3"); // UK site
req.Headers.Add("X-EBAY-API-COMPATIBILITY-LEVEL", "967");
req.Headers.Add("X-EBAY-API-CALL-NAME", "UploadSiteHostedPictures");
req.Headers.Add("X-EBAY-API-IAF-TOKEN", token);
req.Content = content;
var response = await _photoHttp.SendAsync(req);
var responseXml = await response.Content.ReadAsStringAsync();
// Parse URL from XML response
var match = System.Text.RegularExpressions.Regex.Match(
responseXml, @"<FullURL>(.*?)</FullURL>");
return match.Success ? match.Groups[1].Value : null;
}
private JObject BuildListingPolicies(ListingDraft draft)
{
var (serviceCode, costValue) = draft.Postage switch
{
PostageOption.RoyalMailFirstClass => ("UK_RoyalMailFirstClass", "1.50"),
PostageOption.RoyalMailSecondClass => ("UK_RoyalMailSecondClass", "1.20"),
PostageOption.RoyalMailTracked24 => ("UK_RoyalMailTracked24", "2.95"),
PostageOption.RoyalMailTracked48 => ("UK_RoyalMailTracked48", "2.50"),
PostageOption.FreePostage => ("UK_RoyalMailSecondClass", "0.00"),
_ => ("UK_CollectionInPerson", "0.00")
};
// ---- Helpers ----
return new JObject
/// <summary>Creates a pre-authorised request targeting the eBay REST APIs.</summary>
private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
{
["shippingPolicyName"] = "Default",
["paymentPolicyName"] = "Default",
["returnPolicyName"] = "Default",
["shippingCostType"] = "FLAT_RATE",
["shippingOptions"] = new JArray
{
new JObject
{
["optionType"] = "DOMESTIC",
["costType"] = "FLAT_RATE",
["shippingServices"] = new JArray
{
new JObject
{
["shippingServiceCode"] = serviceCode,
["shippingCost"] = new JObject
{
["value"] = costValue,
["currency"] = "GBP"
}
}
}
}
}
};
}
private HttpClient BuildClient(string token)
{
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
return http;
var req = new HttpRequestMessage(method, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
return req;
}
}

View File

@@ -0,0 +1,83 @@
using System.Text.RegularExpressions;
using EbayListingTool.Models;
namespace EbayListingTool.Services;
public record PriceSuggestion(decimal Price, string Source, string Label);
/// <summary>
/// Layered price suggestion: eBay live data → own listing history → AI estimate.
/// Returns the first source that produces a result, labelled so the UI can show
/// where the suggestion came from.
/// </summary>
public class PriceLookupService
{
private readonly EbayPriceResearchService _ebay;
private readonly SavedListingsService _history;
private readonly AiAssistantService _ai;
private static readonly Regex PriceRegex =
new(@"PRICE:\s*(\d+\.?\d*)", RegexOptions.IgnoreCase);
public PriceLookupService(
EbayPriceResearchService ebay,
SavedListingsService history,
AiAssistantService ai)
{
_ebay = ebay;
_history = history;
_ai = ai;
}
public async Task<PriceSuggestion?> GetSuggestionAsync(SavedListing listing)
{
// 1. eBay live listings
try
{
var result = await _ebay.GetLivePricesAsync(listing.Title);
if (result.HasSuggestion)
return new PriceSuggestion(
result.Suggested,
"ebay",
$"eBay suggests £{result.Suggested:F2} (from {result.Count} listings)");
}
catch { /* eBay unavailable — fall through */ }
// 2. Own saved listing history — same category, at least 2 data points
var sameCat = _history.Listings
.Where(l => l.Id != listing.Id
&& !string.IsNullOrWhiteSpace(l.Category)
&& l.Category.Equals(listing.Category, StringComparison.OrdinalIgnoreCase)
&& l.Price > 0)
.Select(l => l.Price)
.ToList();
if (sameCat.Count >= 2)
{
var avg = Math.Round(sameCat.Average(), 2);
return new PriceSuggestion(
avg,
"history",
$"Your avg for {listing.Category}: £{avg:F2} ({sameCat.Count} listings)");
}
// 3. AI estimate
try
{
var response = await _ai.SuggestPriceAsync(listing.Title, listing.ConditionNotes);
var match = PriceRegex.Match(response);
if (match.Success
&& decimal.TryParse(match.Groups[1].Value,
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture,
out var price)
&& price > 0)
{
return new PriceSuggestion(price, "ai", $"AI estimate: £{price:F2}");
}
}
catch { /* AI unavailable */ }
return null;
}
}

View File

@@ -91,6 +91,56 @@ public class SavedListingsService
catch { /* ignore — user may have already deleted it */ }
}
/// <summary>
/// Updates an existing listing's metadata and regenerates its text export file.
/// The listing must be the same object reference held in Listings.
/// </summary>
public void Update(SavedListing listing)
{
if (Directory.Exists(listing.ExportFolder))
{
// Replace the text export — remove old .txt files first
foreach (var old in Directory.GetFiles(listing.ExportFolder, "*.txt"))
{
try { File.Delete(old); } catch { }
}
var safeName = MakeSafeFilename(listing.Title);
var textFile = Path.Combine(listing.ExportFolder, $"{safeName}.txt");
File.WriteAllText(textFile, BuildTextExport(
listing.Title, listing.Description, listing.Price,
listing.Category, listing.ConditionNotes));
}
Persist(); // E1: propagates on failure so caller can show error
}
/// <summary>
/// Copies a source photo into the listing's export folder and returns the destination path.
/// currentCount is the number of photos already in the edit session (used for naming).
/// </summary>
public string CopyPhotoToExportFolder(SavedListing listing, string sourcePath, int currentCount)
{
if (!Directory.Exists(listing.ExportFolder))
throw new DirectoryNotFoundException($"Export folder not found: {listing.ExportFolder}");
var safeName = MakeSafeFilename(listing.Title);
var ext = Path.GetExtension(sourcePath);
var dest = currentCount == 0
? Path.Combine(listing.ExportFolder, $"{safeName}{ext}")
: Path.Combine(listing.ExportFolder, $"{safeName}_{currentCount + 1}{ext}");
// Ensure unique filename if a file with that name already exists
int attempt = 2;
while (File.Exists(dest))
{
dest = Path.Combine(listing.ExportFolder, $"{safeName}_{currentCount + attempt}{ext}");
attempt++;
}
File.Copy(sourcePath, dest);
return dest;
}
// S3: use ProcessStartInfo with FileName so spaces/special chars are handled correctly
public void OpenExportFolder(SavedListing listing)
{

View File

@@ -0,0 +1,25 @@
<Window x:Class="EbayListingTool.Views.EbayLoginWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="Connect to eBay" Width="960" Height="700"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Padding="12,8"
Background="{DynamicResource MahApps.Brushes.Gray9}"
BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource MahApps.Brushes.Gray8}">
<TextBlock Text="Sign in to your eBay account to connect"
FontSize="13" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
</Border>
<wv2:WebView2 Grid.Row="1" x:Name="Browser"/>
</Grid>
</Window>

View File

@@ -0,0 +1,92 @@
using System.IO;
using System.Web;
using System.Windows;
using Microsoft.Web.WebView2.Core;
namespace EbayListingTool.Views;
public partial class EbayLoginWindow : Window
{
public string? AuthCode { get; private set; }
private readonly string _authUrl;
private static readonly string LogFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EbayListingTool", "auth_log.txt");
private static void Log(string msg)
{
try { File.AppendAllText(LogFile, $"{DateTime.Now:HH:mm:ss} [Browser] {msg}\n"); } catch { }
}
public EbayLoginWindow(string authUrl)
{
InitializeComponent();
_authUrl = authUrl;
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
Log($"WebView2 initialising...");
try
{
await Browser.EnsureCoreWebView2Async();
Browser.CoreWebView2.NavigationStarting += CoreWebView2_NavigationStarting;
Log($"Navigating to auth URL");
Browser.CoreWebView2.Navigate(_authUrl);
}
catch (Exception ex)
{
Log($"WebView2 init failed: {ex.Message}");
MessageBox.Show($"Browser could not initialise: {ex.Message}\n\nEnsure Microsoft Edge WebView2 Runtime is installed.",
"Browser Error", MessageBoxButton.OK, MessageBoxImage.Error);
DialogResult = false;
Close();
}
}
private void CoreWebView2_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
{
var url = e.Uri ?? "";
Log($"NavigationStarting → {url}");
if (!url.Contains("ThirdPartyAuth", StringComparison.OrdinalIgnoreCase))
return;
e.Cancel = true;
Log($"ThirdPartyAuth intercepted: {url}");
try
{
var uri = new Uri(url);
var qs = HttpUtility.ParseQueryString(uri.Query);
var code = qs["code"];
Log($"Query params: {string.Join(", ", qs.AllKeys.Select(k => $"{k}={qs[k]?.Substring(0, Math.Min(qs[k]?.Length ?? 0, 30))}"))}");
if (!string.IsNullOrEmpty(code))
{
AuthCode = code;
DialogResult = true;
}
else
{
var error = qs["error"] ?? qs["error_id"] ?? "unknown";
var desc = qs["error_description"] ?? qs["error_message"] ?? "";
Log($"No code — error={error}, desc={desc}");
MessageBox.Show($"eBay login error: {error}\n{desc}", "Login Failed",
MessageBoxButton.OK, MessageBoxImage.Warning);
DialogResult = false;
}
}
catch (Exception ex)
{
Log($"Exception parsing redirect: {ex.Message}");
DialogResult = false;
}
Dispatcher.Invoke(Close);
}
}

View File

@@ -151,13 +151,8 @@
<Grid>
<local:SingleItemView x:Name="SingleView"/>
<!-- Overlay shown when not connected -->
<Border x:Name="NewListingOverlay" Visibility="Visible">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#F0F4FF" Offset="0"/>
<GradientStop Color="#EEF2FF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Border x:Name="NewListingOverlay" Visibility="Visible"
Background="{DynamicResource MahApps.Brushes.ThemeBackground}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
MaxWidth="340">
<!-- eBay logo circle -->
@@ -177,11 +172,11 @@
<TextBlock Text="Connect to eBay"
FontSize="20" FontWeight="Bold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"
Foreground="{DynamicResource MahApps.Brushes.ThemeForeground}"
Margin="0,0,0,8"/>
<TextBlock Text="Sign in with your eBay account to start posting listings and managing your inventory."
FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,24"/>
<Button Click="ConnectBtn_Click"
Style="{StaticResource LockConnectButton}"
@@ -220,13 +215,8 @@
</TabItem.Header>
<Grid>
<local:BulkImportView x:Name="BulkView"/>
<Border x:Name="BulkOverlay" Visibility="Visible">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#F0F4FF" Offset="0"/>
<GradientStop Color="#EEF2FF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Border x:Name="BulkOverlay" Visibility="Visible"
Background="{DynamicResource MahApps.Brushes.ThemeBackground}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
MaxWidth="340">
<!-- eBay logo circle -->
@@ -246,11 +236,11 @@
<TextBlock Text="Connect to eBay"
FontSize="20" FontWeight="Bold"
HorizontalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"
Foreground="{DynamicResource MahApps.Brushes.ThemeForeground}"
Margin="0,0,0,8"/>
<TextBlock Text="Sign in with your eBay account to bulk import and post multiple listings at once."
FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,24"/>
<Button Click="ConnectBtn_Click"
Style="{StaticResource LockConnectButton}"
@@ -283,7 +273,7 @@
VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<TextBlock x:Name="StatusBar" Text="Ready" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray3}"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal">
@@ -291,7 +281,7 @@
Fill="#888" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock x:Name="StatusBarEbay" Text="eBay: disconnected"
FontSize="11" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
</StackPanel>
</Grid>
</Border>

View File

@@ -15,6 +15,7 @@ public partial class MainWindow : MetroWindow
private readonly BulkImportService _bulkService;
private readonly SavedListingsService _savedService;
private readonly EbayPriceResearchService _priceService;
private readonly PriceLookupService _priceLookupService;
public MainWindow()
{
@@ -28,13 +29,14 @@ public partial class MainWindow : MetroWindow
_bulkService = new BulkImportService();
_savedService = new SavedListingsService();
_priceService = new EbayPriceResearchService(_auth);
_priceLookupService = new PriceLookupService(_priceService, _savedService, _aiService);
// Photo Analysis tab — no eBay needed
PhotoView.Initialise(_aiService, _savedService, _priceService);
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
// Saved Listings tab
SavedView.Initialise(_savedService);
SavedView.Initialise(_savedService, _priceLookupService);
// New Listing + Bulk tabs
SingleView.Initialise(_listingService, _categoryService, _aiService, _auth);
@@ -63,12 +65,17 @@ public partial class MainWindow : MetroWindow
MessageBox.Show(ex.Message, "eBay Login Failed",
MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally { ConnectBtn.IsEnabled = true; }
finally
{
ConnectBtn.IsEnabled = true;
UpdateConnectionState(); // always sync UI to actual auth state
}
}
private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
{
_auth.Disconnect();
_listingService.ClearCache(); // clear cached policy/location IDs for next login
UpdateConnectionState();
SetStatus("Disconnected from eBay.");
}

View File

@@ -460,7 +460,7 @@
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
HorizontalAlignment="Center"/>
<TextBlock x:Name="PriceMinText" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
HorizontalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Column="2" HorizontalAlignment="Center">
@@ -473,7 +473,7 @@
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
HorizontalAlignment="Center"/>
<TextBlock x:Name="PriceMaxText" FontSize="11"
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>

View File

@@ -205,13 +205,69 @@
<TextBlock x:Name="DetailTitle" Grid.Column="0"
FontSize="17" FontWeight="Bold" TextWrapping="Wrap"
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
<Border Grid.Column="1"
Background="{DynamicResource MahApps.Brushes.Accent}"
CornerRadius="6" Padding="10,4" Margin="10,0,0,0"
VerticalAlignment="Top">
<!-- Price display + quick revalue -->
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Top">
<!-- Normal price badge + Revalue button -->
<StackPanel x:Name="PriceDisplayRow" Orientation="Horizontal">
<Border Background="{DynamicResource MahApps.Brushes.Accent}"
CornerRadius="6" Padding="10,4">
<TextBlock x:Name="DetailPrice"
FontSize="16" FontWeight="Bold" Foreground="White"/>
</Border>
<Button x:Name="RevalueBtn" Click="RevalueBtn_Click"
Height="28" Padding="8,0" Margin="6,0,0,0"
Style="{DynamicResource MahApps.Styles.Button.Square}"
ToolTip="Quick-change the price">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="11" Height="11"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="Revalue" FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
<!-- Inline revalue editor (hidden by default) -->
<StackPanel x:Name="RevalueRow" Orientation="Vertical"
Visibility="Collapsed" Margin="0,4,0,0">
<StackPanel Orientation="Horizontal">
<mah:NumericUpDown x:Name="RevaluePrice"
Minimum="0" Maximum="99999"
StringFormat="F2" Interval="0.5"
Width="110" Height="30"/>
<Button x:Name="CheckEbayBtn" Click="CheckEbayBtn_Click"
Height="30" Padding="8,0" Margin="6,0,4,0"
Style="{DynamicResource MahApps.Styles.Button.Square}"
ToolTip="Check eBay for a suggested price">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="CheckEbayIcon"
Kind="Magnify" Width="11" Height="11"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock x:Name="CheckEbayText" Text="Check eBay"
FontSize="11" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Click="RevalueSave_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="30" Padding="10,0" Margin="0,0,4,0"
ToolTip="Save new price">
<iconPacks:PackIconMaterial Kind="Check" Width="13" Height="13"/>
</Button>
<Button Click="RevalueCancel_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="30" Padding="8,0"
ToolTip="Cancel">
<iconPacks:PackIconMaterial Kind="Close" Width="11" Height="11"/>
</Button>
</StackPanel>
<TextBlock x:Name="PriceSuggestionLabel"
FontSize="10" Margin="0,5,0,0"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Visibility="Collapsed" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Grid>
<!-- Meta row: category · date -->
@@ -262,6 +318,15 @@
<!-- Action buttons -->
<WrapPanel Orientation="Horizontal">
<Button Click="EditListing_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Pencil" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Edit" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Click="OpenFolderDetail_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="14,0" Margin="0,0,8,6">
@@ -311,6 +376,72 @@
</StackPanel>
</ScrollViewer>
<!-- Edit panel — shown in place of DetailPanel when editing -->
<ScrollViewer x:Name="EditPanel" Visibility="Collapsed"
VerticalScrollBarVisibility="Auto" Padding="18,14">
<StackPanel>
<!-- Title -->
<TextBlock Text="TITLE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditTitle" FontSize="13" Margin="0,0,0,4"
mah:TextBoxHelper.Watermark="Listing title"/>
<!-- Price + Category -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="PRICE (£)" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<mah:NumericUpDown x:Name="EditPrice" Minimum="0" Maximum="99999"
StringFormat="F2" Interval="0.5" Value="0"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="CATEGORY" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCategory" FontSize="12"
mah:TextBoxHelper.Watermark="e.g. Clothing, Shoes &amp; Accessories"/>
</StackPanel>
</Grid>
<!-- Condition notes -->
<TextBlock Text="CONDITION NOTES" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditCondition" FontSize="12" Margin="0,0,0,4"
mah:TextBoxHelper.Watermark="Optional — e.g. minor scuff on base"/>
<!-- Description -->
<TextBlock Text="DESCRIPTION" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBox x:Name="EditDescription" FontSize="12" Margin="0,0,0,4"
TextWrapping="Wrap" AcceptsReturn="True"
Height="130" VerticalScrollBarVisibility="Auto"/>
<!-- Photos -->
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
<TextBlock Text="First photo is the listing cover. Use ◀ ▶ to reorder."
FontSize="10" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Margin="0,0,0,6"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
Margin="0,0,0,10">
<StackPanel x:Name="EditPhotosPanel" Orientation="Horizontal"/>
</ScrollViewer>
<!-- Save / Cancel -->
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<Button x:Name="SaveEditBtn" Click="SaveEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
Height="34" Padding="16,0" Margin="0,0,8,0"
Content="Save Changes"/>
<Button x:Name="CancelEditBtn" Click="CancelEdit_Click"
Style="{DynamicResource MahApps.Styles.Button.Square}"
Height="34" Padding="14,0"
Content="Cancel"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</UserControl>

View File

@@ -6,14 +6,20 @@ using System.Windows.Media.Animation;
using System.Windows.Media.Imaging;
using EbayListingTool.Models;
using EbayListingTool.Services;
using Microsoft.Win32;
namespace EbayListingTool.Views;
public partial class SavedListingsView : UserControl
{
private SavedListingsService? _service;
private PriceLookupService? _priceLookup;
private SavedListing? _selected;
// Edit mode working state
private List<string> _editPhotoPaths = new();
private List<string> _pendingDeletes = new();
// Normal card background — resolved once after load so we can restore it on mouse-leave
private Brush? _cardNormalBg;
private Brush? _cardHoverBg;
@@ -28,9 +34,10 @@ public partial class SavedListingsView : UserControl
};
}
public void Initialise(SavedListingsService service)
public void Initialise(SavedListingsService service, PriceLookupService? priceLookup = null)
{
_service = service;
_priceLookup = priceLookup;
RefreshList();
}
@@ -250,6 +257,10 @@ public partial class SavedListingsView : UserControl
EmptyDetail.Visibility = Visibility.Collapsed;
DetailPanel.Visibility = Visibility.Visible;
// Reset revalue UI
PriceDisplayRow.Visibility = Visibility.Visible;
RevalueRow.Visibility = Visibility.Collapsed;
DetailTitle.Text = listing.Title;
DetailPrice.Text = listing.PriceDisplay;
DetailCategory.Text = listing.Category;
@@ -327,6 +338,379 @@ public partial class SavedListingsView : UserControl
catch { }
}
// ---- Quick revalue ----
private void RevalueBtn_Click(object sender, RoutedEventArgs e)
{
if (_selected == null) return;
RevaluePrice.Value = (double)_selected.Price;
PriceSuggestionLabel.Visibility = Visibility.Collapsed;
PriceSuggestionLabel.Text = "";
CheckEbayBtn.IsEnabled = _priceLookup != null;
PriceDisplayRow.Visibility = Visibility.Collapsed;
RevalueRow.Visibility = Visibility.Visible;
RevaluePrice.Focus();
}
private void RevalueSave_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _service == null) return;
_selected.Price = (decimal)(RevaluePrice.Value ?? 0);
_service.Update(_selected);
DetailPrice.Text = _selected.PriceDisplay;
PriceDisplayRow.Visibility = Visibility.Visible;
RevalueRow.Visibility = Visibility.Collapsed;
PriceSuggestionLabel.Visibility = Visibility.Collapsed;
RefreshList();
}
private void RevalueCancel_Click(object sender, RoutedEventArgs e)
{
PriceDisplayRow.Visibility = Visibility.Visible;
RevalueRow.Visibility = Visibility.Collapsed;
PriceSuggestionLabel.Visibility = Visibility.Collapsed;
}
private async void CheckEbayBtn_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _priceLookup == null) return;
CheckEbayBtn.IsEnabled = false;
CheckEbayIcon.Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.Loading;
CheckEbayText.Text = "Checking…";
PriceSuggestionLabel.Visibility = Visibility.Collapsed;
try
{
var suggestion = await _priceLookup.GetSuggestionAsync(_selected);
if (suggestion != null)
{
RevaluePrice.Value = (double)suggestion.Price;
PriceSuggestionLabel.Text = suggestion.Label;
PriceSuggestionLabel.Visibility = Visibility.Visible;
}
else
{
PriceSuggestionLabel.Text = "No suggestion available — enter price manually.";
PriceSuggestionLabel.Visibility = Visibility.Visible;
}
}
catch (Exception ex)
{
PriceSuggestionLabel.Text = $"Lookup failed: {ex.Message}";
PriceSuggestionLabel.Visibility = Visibility.Visible;
}
finally
{
CheckEbayIcon.Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.Magnify;
CheckEbayText.Text = "Check eBay";
CheckEbayBtn.IsEnabled = true;
}
}
// ---- Edit mode ----
private void EditListing_Click(object sender, RoutedEventArgs e)
{
if (_selected == null) return;
EnterEditMode(_selected);
}
private void EnterEditMode(SavedListing listing)
{
EditTitle.Text = listing.Title;
EditPrice.Value = (double)listing.Price;
EditCategory.Text = listing.Category;
EditCondition.Text = listing.ConditionNotes;
EditDescription.Text = listing.Description;
_editPhotoPaths = new List<string>(listing.PhotoPaths);
_pendingDeletes = new List<string>();
BuildEditPhotoStrip();
DetailPanel.Visibility = Visibility.Collapsed;
EditPanel.Visibility = Visibility.Visible;
}
private void ExitEditMode()
{
EditPanel.Visibility = Visibility.Collapsed;
DetailPanel.Visibility = Visibility.Visible;
}
private void BuildEditPhotoStrip()
{
EditPhotosPanel.Children.Clear();
for (int i = 0; i < _editPhotoPaths.Count; i++)
{
var path = _editPhotoPaths[i];
var index = i; // capture for lambdas
// Outer StackPanel: photo tile on top, reorder buttons below
var outer = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(0, 0, 8, 0),
Width = 120
};
// --- Photo tile (image + cover badge + remove button) ---
var photoGrid = new Grid { Width = 120, Height = 120 };
var imgBorder = new Border
{
Width = 120, Height = 120,
CornerRadius = new CornerRadius(6),
ClipToBounds = true,
Background = (Brush)FindResource("MahApps.Brushes.Gray8")
};
if (File.Exists(path))
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 240;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
imgBorder.Child = new Image { Source = bmp, Stretch = Stretch.UniformToFill };
}
catch { AddPhotoIcon(imgBorder); }
}
else
{
AddPhotoIcon(imgBorder);
}
photoGrid.Children.Add(imgBorder);
// "Cover" badge — top-left, only on first photo
if (i == 0)
{
var badge = new Border
{
CornerRadius = new CornerRadius(3),
Background = (Brush)FindResource("MahApps.Brushes.Accent"),
Padding = new Thickness(4, 1, 4, 1),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(4, 4, 0, 0)
};
badge.Child = new TextBlock { Text = "Cover", FontSize = 9, Foreground = Brushes.White };
photoGrid.Children.Add(badge);
}
// Remove (×) button — top-right corner of image
var removeBtn = new Button
{
Content = "×",
Width = 22, Height = 22,
FontSize = 13,
Padding = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 2, 2, 0),
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
Foreground = Brushes.White,
Background = new SolidColorBrush(Color.FromArgb(200, 40, 40, 40)),
ToolTip = "Remove photo"
};
removeBtn.Click += (s, e) =>
{
_pendingDeletes.Add(_editPhotoPaths[index]);
_editPhotoPaths.RemoveAt(index);
BuildEditPhotoStrip();
};
photoGrid.Children.Add(removeBtn);
outer.Children.Add(photoGrid);
// --- Reorder buttons below the photo ---
var reorderPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 4, 0, 0)
};
var leftBtn = new Button
{
Width = 52, Height = 24,
FontSize = 10,
Padding = new Thickness(0),
Margin = new Thickness(0, 0, 2, 0),
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
ToolTip = "Move left",
IsEnabled = i > 0
};
leftBtn.Content = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center };
((StackPanel)leftBtn.Content).Children.Add(new MahApps.Metro.IconPacks.PackIconMaterial
{
Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronLeft,
Width = 12, Height = 12, VerticalAlignment = VerticalAlignment.Center
});
((StackPanel)leftBtn.Content).Children.Add(new TextBlock { Text = "Move", FontSize = 9, Margin = new Thickness(2, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center });
leftBtn.Click += (s, e) =>
{
(_editPhotoPaths[index], _editPhotoPaths[index - 1]) =
(_editPhotoPaths[index - 1], _editPhotoPaths[index]);
BuildEditPhotoStrip();
};
reorderPanel.Children.Add(leftBtn);
var rightBtn = new Button
{
Width = 52, Height = 24,
FontSize = 10,
Padding = new Thickness(0),
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
ToolTip = "Move right",
IsEnabled = i < _editPhotoPaths.Count - 1
};
rightBtn.Content = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center };
((StackPanel)rightBtn.Content).Children.Add(new TextBlock { Text = "Move", FontSize = 9, Margin = new Thickness(0, 0, 2, 0), VerticalAlignment = VerticalAlignment.Center });
((StackPanel)rightBtn.Content).Children.Add(new MahApps.Metro.IconPacks.PackIconMaterial
{
Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronRight,
Width = 12, Height = 12, VerticalAlignment = VerticalAlignment.Center
});
rightBtn.Click += (s, e) =>
{
(_editPhotoPaths[index], _editPhotoPaths[index + 1]) =
(_editPhotoPaths[index + 1], _editPhotoPaths[index]);
BuildEditPhotoStrip();
};
reorderPanel.Children.Add(rightBtn);
outer.Children.Add(reorderPanel);
EditPhotosPanel.Children.Add(outer);
}
// "Add photos" button at the end of the strip
var addBtn = new Button
{
Width = 60, Height = 120,
Style = (Style)FindResource("MahApps.Styles.Button.Square"),
ToolTip = "Add photos"
};
var addContent = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
addContent.Children.Add(new MahApps.Metro.IconPacks.PackIconMaterial
{
Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.Plus,
Width = 20, Height = 20,
HorizontalAlignment = HorizontalAlignment.Center,
Foreground = (Brush)FindResource("MahApps.Brushes.Accent")
});
addContent.Children.Add(new TextBlock
{
Text = "Add",
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
Foreground = (Brush)FindResource("MahApps.Brushes.Accent"),
Margin = new Thickness(0, 4, 0, 0)
});
addBtn.Content = addContent;
addBtn.Click += AddEditPhoto_Click;
EditPhotosPanel.Children.Add(addBtn);
}
private void AddEditPhoto_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _service == null) return;
var dlg = new OpenFileDialog
{
Multiselect = true,
Filter = "Image files|*.jpg;*.jpeg;*.png;*.gif;*.bmp|All files|*.*",
Title = "Add Photos"
};
if (dlg.ShowDialog() != true) return;
foreach (var src in dlg.FileNames)
{
try
{
var dest = _service.CopyPhotoToExportFolder(_selected, src, _editPhotoPaths.Count);
_editPhotoPaths.Add(dest);
}
catch (Exception ex)
{
MessageBox.Show($"Could not add \"{Path.GetFileName(src)}\": {ex.Message}",
"Add Photo", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
BuildEditPhotoStrip();
}
private void SaveEdit_Click(object sender, RoutedEventArgs e)
{
if (_selected == null || _service == null) return;
var title = EditTitle.Text.Trim();
if (string.IsNullOrWhiteSpace(title))
{
MessageBox.Show("Title cannot be empty.", "Save", MessageBoxButton.OK, MessageBoxImage.Warning);
EditTitle.Focus();
return;
}
_selected.Title = title;
_selected.Price = (decimal)(EditPrice.Value ?? 0);
_selected.Category = EditCategory.Text.Trim();
_selected.ConditionNotes = EditCondition.Text.Trim();
_selected.Description = EditDescription.Text.Trim();
_selected.PhotoPaths = new List<string>(_editPhotoPaths);
try
{
_service.Update(_selected);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to save changes:\n{ex.Message}", "Save Error",
MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
// Delete removed photos from disk now that the save succeeded
foreach (var path in _pendingDeletes)
{
try { if (File.Exists(path)) File.Delete(path); }
catch { /* ignore — file may already be gone */ }
}
_pendingDeletes.Clear();
ExitEditMode();
ShowDetail(_selected, animate: false);
RefreshList();
}
private void CancelEdit_Click(object sender, RoutedEventArgs e)
{
if (_selected != null)
{
// Delete any photos that were added to disk during this edit session but not saved
var originalPaths = new HashSet<string>(_selected.PhotoPaths);
foreach (var path in _editPhotoPaths.Where(p => !originalPaths.Contains(p)))
{
try { if (File.Exists(path)) File.Delete(path); }
catch { }
}
}
_editPhotoPaths.Clear();
_pendingDeletes.Clear();
ExitEditMode();
}
// ---- Button handlers ----
private void OpenExportsDir_Click(object sender, RoutedEventArgs e)

View File

@@ -439,7 +439,7 @@
Foreground="#4CAF50"/>
<TextBlock Text="Posted! " FontWeight="Bold" VerticalAlignment="Center"
Foreground="#4CAF50"/>
<TextBlock x:Name="ListingUrlText" Foreground="#1565C0"
<TextBlock x:Name="ListingUrlText" Foreground="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center"
Cursor="Hand" TextDecorations="Underline"
MouseLeftButtonUp="ListingUrl_Click"/>

View File

@@ -18,8 +18,13 @@ public partial class SingleItemView : UserControl
private ListingDraft _draft = new();
private System.Threading.CancellationTokenSource? _categoryCts;
private bool _suppressCategoryLookup;
private string _suggestedPriceValue = "";
// Photo drag-reorder
private Point _dragStartPoint;
private bool _isDragging;
public SingleItemView()
{
InitializeComponent();
@@ -46,7 +51,7 @@ public partial class SingleItemView : UserControl
}
/// <summary>Pre-fills the form from a Photo Analysis result.</summary>
public void PopulateFromAnalysis(PhotoAnalysisResult result, IReadOnlyList<string> imagePaths, decimal price)
public async void PopulateFromAnalysis(PhotoAnalysisResult result, IReadOnlyList<string> imagePaths, decimal price)
{
// Q6: reset form directly — calling NewListing_Click shows a confirmation dialog which
// is unexpected when arriving here automatically from the Photo Analysis tab.
@@ -67,9 +72,9 @@ public partial class SingleItemView : UserControl
TitleBox.Text = result.Title;
DescriptionBox.Text = result.Description;
PriceBox.Value = (double)price;
CategoryBox.Text = result.CategoryKeyword;
_draft.CategoryName = result.CategoryKeyword;
// Auto-fill the top eBay category from the analysis keyword; user can override
await AutoFillCategoryAsync(result.CategoryKeyword);
// Q1: load all photos from analysis
var validPaths = imagePaths.Where(p => !string.IsNullOrEmpty(p) && File.Exists(p)).ToArray();
@@ -113,6 +118,10 @@ public partial class SingleItemView : UserControl
{
var title = await _aiService.GenerateTitleAsync(current, condition);
TitleBox.Text = title.Trim().TrimEnd('.').Trim('"');
// Auto-fill category from the generated title if not already set
if (string.IsNullOrWhiteSpace(_draft.CategoryId))
await AutoFillCategoryAsync(TitleBox.Text);
}
catch (Exception ex)
{
@@ -125,6 +134,8 @@ public partial class SingleItemView : UserControl
private async void CategoryBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (_suppressCategoryLookup) return;
_categoryCts?.Cancel();
_categoryCts?.Dispose();
_categoryCts = new System.Threading.CancellationTokenSource();
@@ -207,6 +218,38 @@ public partial class SingleItemView : UserControl
}
}
/// <summary>
/// Fetches the top eBay category suggestion for <paramref name="keyword"/> and auto-fills
/// the category fields. The suggestions list is shown so the user can override.
/// </summary>
private async Task AutoFillCategoryAsync(string keyword)
{
if (_categoryService == null || string.IsNullOrWhiteSpace(keyword)) return;
try
{
var suggestions = await _categoryService.GetCategorySuggestionsAsync(keyword);
if (suggestions.Count == 0) return;
var top = suggestions[0];
_suppressCategoryLookup = true;
try
{
_draft.CategoryId = top.CategoryId;
_draft.CategoryName = top.CategoryName;
CategoryBox.Text = top.CategoryName;
CategoryIdLabel.Text = $"ID: {top.CategoryId}";
}
finally { _suppressCategoryLookup = false; }
// Show the full list so user can see alternatives and override
CategorySuggestionsList.ItemsSource = suggestions;
CategorySuggestionsList.Visibility = suggestions.Count > 1
? Visibility.Visible : Visibility.Collapsed;
}
catch { /* non-critical — leave category blank if lookup fails */ }
}
// ---- Condition ----
private void ConditionBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
@@ -329,25 +372,35 @@ public partial class SingleItemView : UserControl
if (!imageExts.Contains(Path.GetExtension(path))) continue;
if (_draft.PhotoPaths.Count >= 12) break;
if (_draft.PhotoPaths.Contains(path)) continue;
_draft.PhotoPaths.Add(path);
AddPhotoThumbnail(path);
}
RebuildPhotoThumbnails();
}
/// <summary>
/// Clears and recreates all photo thumbnails from <see cref="ListingDraft.PhotoPaths"/>.
/// Called after any add, remove, or reorder operation so the panel always matches the list.
/// </summary>
private void RebuildPhotoThumbnails()
{
PhotosPanel.Children.Clear();
for (int i = 0; i < _draft.PhotoPaths.Count; i++)
AddPhotoThumbnail(_draft.PhotoPaths[i], i);
UpdatePhotoPanel();
}
private void AddPhotoThumbnail(string path)
private void AddPhotoThumbnail(string path, int index)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.DecodePixelWidth = 128;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze(); // M2
bmp.Freeze();
var img = new System.Windows.Controls.Image
{
@@ -356,12 +409,9 @@ public partial class SingleItemView : UserControl
Source = bmp,
ToolTip = Path.GetFileName(path)
};
img.Clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 72, 72), 4, 4);
// Rounded clip on the image
img.Clip = new System.Windows.Media.RectangleGeometry(
new Rect(0, 0, 72, 72), 4, 4);
// Remove button — shown on hover via opacity triggers
// Remove button
var removeBtn = new Button
{
Width = 18, Height = 18,
@@ -375,38 +425,116 @@ public partial class SingleItemView : UserControl
System.Windows.Media.Color.FromArgb(200, 30, 30, 30)),
Foreground = System.Windows.Media.Brushes.White,
BorderThickness = new Thickness(0),
FontSize = 11,
FontWeight = FontWeights.Bold,
FontSize = 11, FontWeight = FontWeights.Bold,
Content = "✕",
Opacity = 0
};
removeBtn.Click += (s, e) =>
{
e.Handled = true; // don't bubble and trigger drag
_draft.PhotoPaths.Remove(path);
RebuildPhotoThumbnails();
};
// "Cover" badge on the first photo — it becomes the eBay gallery hero image
Border? coverBadge = null;
if (index == 0)
{
coverBadge = new Border
{
CornerRadius = new CornerRadius(3),
Background = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromArgb(210, 60, 90, 200)),
Padding = new Thickness(3, 1, 3, 1),
Margin = new Thickness(2, 2, 0, 0),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
IsHitTestVisible = false, // don't block drag
Child = new TextBlock
{
Text = "Cover",
FontSize = 8,
FontWeight = FontWeights.SemiBold,
Foreground = System.Windows.Media.Brushes.White
}
};
}
// Container grid — shows remove button on mouse over
var container = new Grid
{
Width = 72, Height = 72,
Margin = new Thickness(4),
Cursor = Cursors.Hand
Cursor = Cursors.SizeAll, // signal draggability
AllowDrop = true,
Tag = path // stable identifier used by drop handler
};
container.Children.Add(img);
if (coverBadge != null) container.Children.Add(coverBadge);
container.Children.Add(removeBtn);
// Hover: reveal remove button
container.MouseEnter += (s, e) => removeBtn.Opacity = 1;
container.MouseLeave += (s, e) => removeBtn.Opacity = 0;
removeBtn.Click += (s, e) => RemovePhoto(path, container);
// Drag initiation
container.MouseLeftButtonDown += (s, e) =>
{
_dragStartPoint = e.GetPosition(null);
};
container.MouseMove += (s, e) =>
{
if (e.LeftButton != MouseButtonState.Pressed || _isDragging) return;
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStartPoint.X) > SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(pos.Y - _dragStartPoint.Y) > SystemParameters.MinimumVerticalDragDistance)
{
_isDragging = true;
DragDrop.DoDragDrop(container, path, DragDropEffects.Move);
_isDragging = false;
}
};
// Drop target
container.DragOver += (s, e) =>
{
if (e.Data.GetDataPresent(typeof(string)) &&
(string)e.Data.GetData(typeof(string)) != path)
{
e.Effects = DragDropEffects.Move;
container.Opacity = 0.45; // dim to signal insertion point
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
};
container.DragLeave += (s, e) => container.Opacity = 1.0;
container.Drop += (s, e) =>
{
container.Opacity = 1.0;
if (!e.Data.GetDataPresent(typeof(string))) return;
var sourcePath = (string)e.Data.GetData(typeof(string));
var targetPath = (string)container.Tag;
if (sourcePath == targetPath) return;
var sourceIdx = _draft.PhotoPaths.IndexOf(sourcePath);
var targetIdx = _draft.PhotoPaths.IndexOf(targetPath);
if (sourceIdx < 0 || targetIdx < 0) return;
_draft.PhotoPaths.RemoveAt(sourceIdx);
_draft.PhotoPaths.Insert(targetIdx, sourcePath);
RebuildPhotoThumbnails();
e.Handled = true;
};
PhotosPanel.Children.Add(container);
}
catch { /* skip unreadable files */ }
}
private void RemovePhoto(string path, UIElement thumb)
{
_draft.PhotoPaths.Remove(path);
PhotosPanel.Children.Remove(thumb);
UpdatePhotoPanel();
}
private void UpdatePhotoPanel()
{
var count = _draft.PhotoPaths.Count;
@@ -421,8 +549,7 @@ public partial class SingleItemView : UserControl
private void ClearPhotos_Click(object sender, RoutedEventArgs e)
{
_draft.PhotoPaths.Clear();
PhotosPanel.Children.Clear();
UpdatePhotoPanel();
RebuildPhotoThumbnails();
}
// ---- Post / Save ----

View File

@@ -0,0 +1,14 @@
{
"Ebay": {
"ClientId": "PeterFos-Lister-SBX-f6c15d8b1-1e21a7cf",
"ClientSecret": "SBX-6c15d8b15850-bd12-45b9-a4d9-d5d7",
"RuName": "Peter_Foster-PeterFos-Lister-eutksmb",
"Sandbox": true,
"RedirectPort": 8080,
"DefaultPostcode": "NR1 1AA"
},
"OpenRouter": {
"ApiKey": "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",
"Model": "anthropic/claude-sonnet-4-5"
}
}