Compare commits
13 Commits
426089fb3e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edbce97a74 | ||
|
|
f65521b9ab | ||
|
|
da0efc1374 | ||
|
|
1ff9d3d78b | ||
|
|
f4e7854297 | ||
|
|
56e0be83d2 | ||
|
|
5cf7f1b8c6 | ||
|
|
15726a4f18 | ||
|
|
bd59db724a | ||
|
|
48c6049dfc | ||
|
|
ffba3ce1b6 | ||
|
|
d3bdcc1061 | ||
|
|
6efa5df2c6 |
19
.gitattributes
vendored
Normal file
19
.gitattributes
vendored
Normal 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
2
.gitignore
vendored
@@ -5,8 +5,6 @@ obj/
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Config with secrets — never commit
|
||||
EbayListingTool/appsettings.json
|
||||
|
||||
# Rider / JetBrains
|
||||
.idea/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
83
EbayListingTool/Services/PriceLookupService.cs
Normal file
83
EbayListingTool/Services/PriceLookupService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
25
EbayListingTool/Views/EbayLoginWindow.xaml
Normal file
25
EbayListingTool/Views/EbayLoginWindow.xaml
Normal 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>
|
||||
92
EbayListingTool/Views/EbayLoginWindow.xaml.cs
Normal file
92
EbayListingTool/Views/EbayLoginWindow.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
14
EbayListingTool/appsettings.json
Normal file
14
EbayListingTool/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user