Compare commits
43 Commits
426089fb3e
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| cdde3ae195 | |||
| c51342f46e | |||
| b3bb2e704e | |||
| 60ed955143 | |||
| 1a70e5f58a | |||
| 53a4594e1c | |||
| d84a61b918 | |||
| a780ec0089 | |||
| c34a2fd5a5 | |||
| 1c906c5f6c | |||
| ded36a27cf | |||
| 554a280caa | |||
| 135fd07f54 | |||
| 1533945126 | |||
| 61a48e8f28 | |||
| da992c94cd | |||
| 1de957f73e | |||
| 2903035d8c | |||
| e936877542 | |||
| 3b0ed62e7c | |||
| 3cef63a472 | |||
| 1f7f28b3b9 | |||
| 299c08248c | |||
| 48be042aa2 | |||
| bb5cd09ce2 | |||
| 987c778ae2 | |||
| bad466be1f | |||
| 7507030f72 | |||
| e9c5464df0 | |||
| e3827d97e8 | |||
|
|
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
|
*.suo
|
||||||
.vs/
|
.vs/
|
||||||
|
|
||||||
# Config with secrets — never commit
|
|
||||||
EbayListingTool/appsettings.json
|
|
||||||
|
|
||||||
# Rider / JetBrains
|
# Rider / JetBrains
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Threading;
|
using System.Windows.Threading;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -22,7 +22,8 @@ public partial class App : Application
|
|||||||
|
|
||||||
Configuration = new ConfigurationBuilder()
|
Configuration = new ConfigurationBuilder()
|
||||||
.SetBasePath(AppContext.BaseDirectory)
|
.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();
|
.Build();
|
||||||
|
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UseWPF>true</UseWPF>
|
<UseWPF>true</UseWPF>
|
||||||
<AssemblyName>EbayListingTool</AssemblyName>
|
<AssemblyName>EbayListingTool</AssemblyName>
|
||||||
|
<ApplicationIcon>app_icon.ico</ApplicationIcon>
|
||||||
<RootNamespace>EbayListingTool</RootNamespace>
|
<RootNamespace>EbayListingTool</RootNamespace>
|
||||||
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -24,9 +25,11 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2651.64" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Resource Include="app_icon.ico" />
|
||||||
<None Update="appsettings.json">
|
<None Update="appsettings.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace EbayListingTool.Models;
|
namespace EbayListingTool.Models;
|
||||||
@@ -150,12 +150,12 @@ public class ListingDraft : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string ConditionId => Condition switch
|
public string ConditionId => Condition switch
|
||||||
{
|
{
|
||||||
ItemCondition.New => "1000",
|
ItemCondition.New => "NEW",
|
||||||
ItemCondition.OpenBox => "1500",
|
ItemCondition.OpenBox => "LIKE_NEW",
|
||||||
ItemCondition.Refurbished => "2500",
|
ItemCondition.Refurbished => "SELLER_REFURBISHED",
|
||||||
ItemCondition.Used => "3000",
|
ItemCondition.Used => "USED_VERY_GOOD",
|
||||||
ItemCondition.ForPartsOrNotWorking => "7000",
|
ItemCondition.ForPartsOrNotWorking => "FOR_PARTS_OR_NOT_WORKING",
|
||||||
_ => "3000"
|
_ => "USED_VERY_GOOD"
|
||||||
};
|
};
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace EbayListingTool.Models;
|
namespace EbayListingTool.Models;
|
||||||
|
|
||||||
public class PhotoAnalysisResult
|
public class PhotoAnalysisResult
|
||||||
{
|
{
|
||||||
@@ -18,6 +18,6 @@ public class PhotoAnalysisResult
|
|||||||
|
|
||||||
public string PriceRangeDisplay =>
|
public string PriceRangeDisplay =>
|
||||||
PriceMin > 0 && PriceMax > 0
|
PriceMin > 0 && PriceMax > 0
|
||||||
? $"£{PriceMin:F2} – £{PriceMax:F2} (suggested £{PriceSuggested:F2})"
|
? $"\u00A3{PriceMin:F2} – \u00A3{PriceMax:F2} (suggested \u00A3{PriceSuggested:F2})"
|
||||||
: PriceSuggested > 0 ? $"£{PriceSuggested:F2}" : "";
|
: PriceSuggested > 0 ? $"\u00A3{PriceSuggested:F2}" : "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace EbayListingTool.Models;
|
namespace EbayListingTool.Models;
|
||||||
|
|
||||||
public class SavedListing
|
public class SavedListing
|
||||||
{
|
{
|
||||||
@@ -11,12 +11,41 @@ public class SavedListing
|
|||||||
public string ConditionNotes { get; set; } = "";
|
public string ConditionNotes { get; set; } = "";
|
||||||
public string ExportFolder { get; set; } = "";
|
public string ExportFolder { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>eBay category ID — stored at save time so we can post without re-looking it up.</summary>
|
||||||
|
public string CategoryId { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>Item condition — defaults to Used for existing records without this field.</summary>
|
||||||
|
public ItemCondition Condition { get; set; } = ItemCondition.Used;
|
||||||
|
|
||||||
|
/// <summary>Listing format — defaults to FixedPrice for existing records.</summary>
|
||||||
|
public ListingFormat Format { get; set; } = ListingFormat.FixedPrice;
|
||||||
|
|
||||||
|
/// <summary>Seller postcode — populated from appsettings default at save time.</summary>
|
||||||
|
public string Postcode { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Absolute paths to photos inside ExportFolder.</summary>
|
/// <summary>Absolute paths to photos inside ExportFolder.</summary>
|
||||||
public List<string> PhotoPaths { get; set; } = new();
|
public List<string> PhotoPaths { get; set; } = new();
|
||||||
|
|
||||||
public string FirstPhotoPath => PhotoPaths.Count > 0 ? PhotoPaths[0] : "";
|
public string FirstPhotoPath => PhotoPaths.Count > 0 ? PhotoPaths[0] : "";
|
||||||
|
|
||||||
public string PriceDisplay => Price > 0 ? $"£{Price:F2}" : "—";
|
public string PriceDisplay => Price > 0 ? $"\u00A3{Price:F2}" : "—";
|
||||||
|
|
||||||
public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm");
|
public string SavedAtDisplay => SavedAt.ToLocalTime().ToString("d MMM yyyy, HH:mm");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts this saved draft back into a ListingDraft suitable for PostListingAsync.
|
||||||
|
/// </summary>
|
||||||
|
public ListingDraft ToListingDraft() => new ListingDraft
|
||||||
|
{
|
||||||
|
Title = Title,
|
||||||
|
Description = Description,
|
||||||
|
Price = Price,
|
||||||
|
CategoryId = CategoryId,
|
||||||
|
CategoryName = Category,
|
||||||
|
Condition = Condition,
|
||||||
|
Format = Format,
|
||||||
|
Postcode = Postcode,
|
||||||
|
PhotoPaths = new List<string>(PhotoPaths),
|
||||||
|
Quantity = 1
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using EbayListingTool.Models;
|
using EbayListingTool.Models;
|
||||||
@@ -65,7 +65,7 @@ public class AiAssistantService
|
|||||||
string priceContext = "";
|
string priceContext = "";
|
||||||
if (soldPrices != null && soldPrices.Any())
|
if (soldPrices != null && soldPrices.Any())
|
||||||
{
|
{
|
||||||
var prices = soldPrices.Select(p => $"£{p:F2}");
|
var prices = soldPrices.Select(p => $"\u00A3{p:F2}");
|
||||||
priceContext = $"\nRecent eBay UK sold prices for similar items: {string.Join(", ", prices)}";
|
priceContext = $"\nRecent eBay UK sold prices for similar items: {string.Join(", ", prices)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ public class AiAssistantService
|
|||||||
RefineWithCorrectionsAsync(string title, string description, decimal price, string corrections)
|
RefineWithCorrectionsAsync(string title, string description, decimal price, string corrections)
|
||||||
{
|
{
|
||||||
var priceContext = price > 0
|
var priceContext = price > 0
|
||||||
? $"Current price: £{price:F2}\n\n"
|
? $"Current price: \u00A3{price:F2}\n\n"
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
var prompt =
|
var prompt =
|
||||||
@@ -246,7 +246,7 @@ public class AiAssistantService
|
|||||||
" \"confidence_notes\": \"one sentence explaining confidence level, e.g. brand clearly visible on label\"\n" +
|
" \"confidence_notes\": \"one sentence explaining confidence level, e.g. brand clearly visible on label\"\n" +
|
||||||
"}\n\n" +
|
"}\n\n" +
|
||||||
"For prices: research realistic eBay UK sold prices in your knowledge. " +
|
"For prices: research realistic eBay UK sold prices in your knowledge. " +
|
||||||
"price_suggested should be a good Buy It Now price. Use GBP numbers only (no £ symbol).";
|
"price_suggested should be a good Buy It Now price. Use GBP numbers only (no \u00A3 symbol).";
|
||||||
|
|
||||||
var json = await CallWithVisionAsync(dataUrls, prompt);
|
var json = await CallWithVisionAsync(dataUrls, prompt);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
using EbayListingTool.Models;
|
using EbayListingTool.Models;
|
||||||
|
using EbayListingTool.Views;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
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",
|
||||||
"https://api.ebay.com/oauth/api_scope/sell.inventory",
|
"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.fulfillment",
|
||||||
"https://api.ebay.com/oauth/api_scope/sell.account"
|
"https://api.ebay.com/oauth/api_scope/sell.account"
|
||||||
];
|
];
|
||||||
@@ -69,7 +68,6 @@ public class EbayAuthService
|
|||||||
|
|
||||||
public async Task<string> LoginAsync()
|
public async Task<string> LoginAsync()
|
||||||
{
|
{
|
||||||
var redirectUri = $"http://localhost:{_settings.RedirectPort}/";
|
|
||||||
var scopeString = Uri.EscapeDataString(string.Join(" ", Scopes));
|
var scopeString = Uri.EscapeDataString(string.Join(" ", Scopes));
|
||||||
var authBase = _settings.Sandbox
|
var authBase = _settings.Sandbox
|
||||||
? "https://auth.sandbox.ebay.com/oauth2/authorize"
|
? "https://auth.sandbox.ebay.com/oauth2/authorize"
|
||||||
@@ -79,39 +77,39 @@ public class EbayAuthService
|
|||||||
$"&redirect_uri={Uri.EscapeDataString(_settings.RuName)}" +
|
$"&redirect_uri={Uri.EscapeDataString(_settings.RuName)}" +
|
||||||
$"&response_type=code&scope={scopeString}";
|
$"&response_type=code&scope={scopeString}";
|
||||||
|
|
||||||
// Start local listener before opening browser
|
Log($"LoginAsync start — RuName={_settings.RuName}, ClientId={_settings.ClientId}");
|
||||||
using var listener = new HttpListener();
|
|
||||||
listener.Prefixes.Add(redirectUri);
|
|
||||||
listener.Start();
|
|
||||||
|
|
||||||
// Open browser
|
string? code = null;
|
||||||
Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });
|
|
||||||
|
|
||||||
// Wait for redirect with code (60s timeout)
|
// Open embedded browser dialog on UI thread and block until it closes
|
||||||
var contextTask = listener.GetContextAsync();
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
if (await Task.WhenAny(contextTask, Task.Delay(TimeSpan.FromSeconds(60))) != contextTask)
|
|
||||||
{
|
{
|
||||||
listener.Stop();
|
var win = new EbayLoginWindow(authUrl);
|
||||||
throw new TimeoutException("eBay login timed out. Please try again.");
|
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;
|
Log($"Auth code received (length={code.Length})");
|
||||||
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();
|
|
||||||
|
|
||||||
await ExchangeCodeForTokenAsync(code);
|
await ExchangeCodeForTokenAsync(code);
|
||||||
return _token!.EbayUsername;
|
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)
|
private async Task ExchangeCodeForTokenAsync(string code)
|
||||||
{
|
{
|
||||||
var tokenUrl = _settings.Sandbox
|
var tokenUrl = _settings.Sandbox
|
||||||
@@ -130,20 +128,33 @@ public class EbayAuthService
|
|||||||
["redirect_uri"] = _settings.RuName
|
["redirect_uri"] = _settings.RuName
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Log($"Token exchange → POST {tokenUrl}");
|
||||||
var response = await _http.SendAsync(codeRequest);
|
var response = await _http.SendAsync(codeRequest);
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
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)
|
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 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
|
_token = new EbayToken
|
||||||
{
|
{
|
||||||
AccessToken = obj["access_token"]!.ToString(),
|
AccessToken = accessToken,
|
||||||
RefreshToken = obj["refresh_token"]!.ToString(),
|
RefreshToken = refreshToken,
|
||||||
AccessTokenExpiry = DateTime.UtcNow.AddSeconds(obj["expires_in"]!.Value<int>()),
|
AccessTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn),
|
||||||
RefreshTokenExpiry = DateTime.UtcNow.AddSeconds(obj["refresh_token_expires_in"]!.Value<int>()),
|
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
|
// Fetch username
|
||||||
_token.EbayUsername = await FetchUsernameAsync(_token.AccessToken);
|
_token.EbayUsername = await FetchUsernameAsync(_token.AccessToken);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public class EbayCategoryService
|
|||||||
{
|
{
|
||||||
private readonly EbayAuthService _auth;
|
private readonly EbayAuthService _auth;
|
||||||
|
|
||||||
|
// Static client — avoids socket exhaustion from per-call `new HttpClient()`
|
||||||
|
private static readonly HttpClient _http = new();
|
||||||
|
|
||||||
public EbayCategoryService(EbayAuthService auth)
|
public EbayCategoryService(EbayAuthService auth)
|
||||||
{
|
{
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
@@ -26,15 +29,18 @@ public class EbayCategoryService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var token = await _auth.GetValidAccessTokenAsync();
|
// Taxonomy API supports app-level tokens — no user login required
|
||||||
using var http = new HttpClient();
|
var token = await _auth.GetAppTokenAsync();
|
||||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
using var request = new HttpRequestMessage(HttpMethod.Get,
|
||||||
http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
|
$"{_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" +
|
var response = await _http.SendAsync(request);
|
||||||
$"?q={Uri.EscapeDataString(query)}";
|
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 obj = JObject.Parse(json);
|
||||||
|
|
||||||
var results = new List<CategorySuggestion>();
|
var results = new List<CategorySuggestion>();
|
||||||
|
|||||||
@@ -12,20 +12,48 @@ public class EbayListingService
|
|||||||
private readonly EbayAuthService _auth;
|
private readonly EbayAuthService _auth;
|
||||||
private readonly EbayCategoryService _categoryService;
|
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)
|
||||||
|
|
||||||
|
private static readonly JsonSerializerSettings _jsonSettings = new()
|
||||||
|
{ NullValueHandling = NullValueHandling.Ignore };
|
||||||
|
|
||||||
|
private static readonly string _logPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"EbayListingTool", "crash_log.txt");
|
||||||
|
|
||||||
|
// Per-session cache of eBay account IDs — fetched once, reused for every listing
|
||||||
|
private string? _fulfillmentPolicyId;
|
||||||
|
private string? _paymentPolicyId;
|
||||||
|
private string? _returnPolicyId;
|
||||||
|
private string? _merchantLocationKey;
|
||||||
|
private bool _triedPolicySetup;
|
||||||
|
|
||||||
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
|
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
|
||||||
{
|
{
|
||||||
_auth = auth;
|
_auth = auth;
|
||||||
_categoryService = categoryService;
|
_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;
|
||||||
|
_triedPolicySetup = false;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> PostListingAsync(ListingDraft draft)
|
public async Task<string> PostListingAsync(ListingDraft draft)
|
||||||
{
|
{
|
||||||
var token = await _auth.GetValidAccessTokenAsync();
|
var token = await _auth.GetValidAccessTokenAsync();
|
||||||
|
|
||||||
// 1. Upload photos and get URLs
|
await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
|
||||||
|
|
||||||
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
|
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
|
||||||
|
|
||||||
// 2. Resolve category if not set
|
|
||||||
if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName))
|
if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName))
|
||||||
{
|
{
|
||||||
draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName)
|
draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName)
|
||||||
@@ -35,13 +63,8 @@ public class EbayListingService
|
|||||||
if (string.IsNullOrEmpty(draft.CategoryId))
|
if (string.IsNullOrEmpty(draft.CategoryId))
|
||||||
throw new InvalidOperationException("Please select a category before posting.");
|
throw new InvalidOperationException("Please select a category before posting.");
|
||||||
|
|
||||||
// 3. Create inventory item
|
|
||||||
await CreateInventoryItemAsync(draft, imageUrls, token);
|
await CreateInventoryItemAsync(draft, imageUrls, token);
|
||||||
|
|
||||||
// 4. Create offer
|
|
||||||
var offerId = await CreateOfferAsync(draft, token);
|
var offerId = await CreateOfferAsync(draft, token);
|
||||||
|
|
||||||
// 5. Publish offer → get item ID
|
|
||||||
var itemId = await PublishOfferAsync(offerId, token);
|
var itemId = await PublishOfferAsync(offerId, token);
|
||||||
|
|
||||||
draft.EbayItemId = itemId;
|
draft.EbayItemId = itemId;
|
||||||
@@ -51,60 +74,268 @@ public class EbayListingService
|
|||||||
return draft.EbayListingUrl;
|
return draft.EbayListingUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Setup: policies + location ----
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (!_triedPolicySetup && json.Contains("20403"))
|
||||||
|
{
|
||||||
|
_triedPolicySetup = true;
|
||||||
|
await SetupDefaultBusinessPoliciesAsync(token);
|
||||||
|
await EnsurePoliciesAndLocationAsync(token, postcode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Could not fetch fulfillment policies ({(int)res.StatusCode}): {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray;
|
||||||
|
_fulfillmentPolicyId = arr?.Count > 0
|
||||||
|
? arr[0]["fulfillmentPolicyId"]?.ToString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (_fulfillmentPolicyId == null)
|
||||||
|
{
|
||||||
|
if (!_triedPolicySetup)
|
||||||
|
{
|
||||||
|
_triedPolicySetup = true;
|
||||||
|
await SetupDefaultBusinessPoliciesAsync(token);
|
||||||
|
await EnsurePoliciesAndLocationAsync(token, postcode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"No fulfillment policy found on your eBay account.\n\n" +
|
||||||
|
"Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_paymentPolicyId == null)
|
||||||
|
{
|
||||||
|
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 \u2192 Account \u2192 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 \u2192 Account \u2192 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_merchantLocationKey == null)
|
||||||
|
{
|
||||||
|
await CreateMerchantLocationAsync(token, postcode);
|
||||||
|
_merchantLocationKey = "home";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogSetup(string msg)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_logPath)!);
|
||||||
|
File.AppendAllText(_logPath, $"{DateTime.Now:HH:mm:ss} [Setup] {msg}{Environment.NewLine}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetupDefaultBusinessPoliciesAsync(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var optInBody = JsonConvert.SerializeObject(new { programType = "SELLING_POLICY_MANAGEMENT" });
|
||||||
|
using var req = MakeRequest(HttpMethod.Post,
|
||||||
|
$"{_auth.BaseUrl}/sell/account/v1/program/opt_in", token);
|
||||||
|
req.Content = new StringContent(optInBody, Encoding.UTF8, "application/json");
|
||||||
|
var res = await _http.SendAsync(req);
|
||||||
|
var body = await res.Content.ReadAsStringAsync();
|
||||||
|
LogSetup($"opt_in \u2192 {(int)res.StatusCode} {body}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { LogSetup($"opt_in exception: {ex.Message}"); }
|
||||||
|
|
||||||
|
await Task.WhenAll(
|
||||||
|
TryCreatePolicyAsync(token, "fulfillment_policy", new
|
||||||
|
{
|
||||||
|
name = "Standard UK Shipping",
|
||||||
|
marketplaceId = "EBAY_GB",
|
||||||
|
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
|
||||||
|
handlingTime = new { value = 1, unit = "DAY" },
|
||||||
|
shippingOptions = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
optionType = "DOMESTIC",
|
||||||
|
costType = "FLAT_RATE",
|
||||||
|
shippingServices = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
sortOrder = 1,
|
||||||
|
shippingCarrierCode = "ROYALMAIL",
|
||||||
|
shippingServiceCode = "UK_OtherCourier",
|
||||||
|
shippingCost = new { value = "2.85", currency = "GBP" },
|
||||||
|
additionalShippingCost = new { value = "0.00", currency = "GBP" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
TryCreatePolicyAsync(token, "payment_policy", new
|
||||||
|
{
|
||||||
|
name = "Managed Payments",
|
||||||
|
marketplaceId = "EBAY_GB",
|
||||||
|
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
|
||||||
|
paymentMethods = Array.Empty<object>()
|
||||||
|
}),
|
||||||
|
TryCreatePolicyAsync(token, "return_policy", new
|
||||||
|
{
|
||||||
|
name = "Standard Returns",
|
||||||
|
marketplaceId = "EBAY_GB",
|
||||||
|
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
|
||||||
|
returnsAccepted = true,
|
||||||
|
returnPeriod = new { value = 30, unit = "DAY" },
|
||||||
|
refundMethod = "MONEY_BACK",
|
||||||
|
returnShippingCostPayer = "BUYER"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryCreatePolicyAsync(string token, string policyType, object body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = MakeRequest(HttpMethod.Post,
|
||||||
|
$"{_auth.BaseUrl}/sell/account/v1/{policyType}", token);
|
||||||
|
req.Content = new StringContent(
|
||||||
|
JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
|
||||||
|
var res = await _http.SendAsync(req);
|
||||||
|
var respBody = await res.Content.ReadAsStringAsync();
|
||||||
|
LogSetup($"{policyType} create \u2192 {(int)res.StatusCode} {respBody}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { LogSetup($"{policyType} exception: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateMerchantLocationAsync(string token, string postcode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(postcode))
|
||||||
|
postcode = "N/A";
|
||||||
|
|
||||||
|
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)
|
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
|
var inventoryItem = new
|
||||||
{
|
{
|
||||||
availability = new
|
availability = new
|
||||||
{
|
{
|
||||||
shipToLocationAvailability = new
|
shipToLocationAvailability = new { quantity = draft.Quantity }
|
||||||
{
|
|
||||||
quantity = draft.Quantity
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
condition = draft.ConditionId,
|
condition = draft.ConditionId,
|
||||||
conditionDescription = draft.Condition == ItemCondition.Used ? "Used - see photos" : null,
|
conditionDescription = draft.Condition == ItemCondition.Used ? "Used \u2014 see photos" : null,
|
||||||
description = draft.Description,
|
|
||||||
title = draft.Title,
|
|
||||||
product = new
|
product = new
|
||||||
{
|
{
|
||||||
title = draft.Title,
|
title = draft.Title,
|
||||||
description = draft.Description,
|
description = draft.Description,
|
||||||
imageUrls = imageUrls.Count > 0 ? imageUrls : null,
|
imageUrls = imageUrls.Count > 0 ? imageUrls : null,
|
||||||
aspects = aspects.Count > 0 ? aspects : null
|
aspects = (object?)null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonConvert.SerializeObject(inventoryItem, new JsonSerializerSettings
|
|
||||||
{
|
|
||||||
NullValueHandling = NullValueHandling.Ignore
|
|
||||||
});
|
|
||||||
|
|
||||||
var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
|
var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
|
||||||
var request = new HttpRequestMessage(HttpMethod.Put, url)
|
using var req = MakeRequest(HttpMethod.Put, url, token);
|
||||||
{
|
req.Content = new StringContent(JsonConvert.SerializeObject(inventoryItem, _jsonSettings), Encoding.UTF8, "application/json");
|
||||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
req.Content.Headers.Add("Content-Language", "en-GB");
|
||||||
};
|
|
||||||
request.Content.Headers.Add("Content-Language", "en-GB");
|
|
||||||
|
|
||||||
var response = await http.SendAsync(request);
|
var res = await _http.SendAsync(req);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!res.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var error = await response.Content.ReadAsStringAsync();
|
var err = await res.Content.ReadAsStringAsync();
|
||||||
throw new HttpRequestException($"Failed to create inventory item: {error}");
|
throw new HttpRequestException($"Failed to create inventory item: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Offer ----
|
||||||
|
|
||||||
private async Task<string> CreateOfferAsync(ListingDraft draft, string token)
|
private async Task<string> CreateOfferAsync(ListingDraft draft, string token)
|
||||||
{
|
{
|
||||||
using var http = BuildClient(token);
|
|
||||||
|
|
||||||
var listingPolicies = BuildListingPolicies(draft);
|
|
||||||
|
|
||||||
var offer = new
|
var offer = new
|
||||||
{
|
{
|
||||||
sku = draft.Sku,
|
sku = draft.Sku,
|
||||||
@@ -113,81 +344,75 @@ public class EbayListingService
|
|||||||
availableQuantity = draft.Quantity,
|
availableQuantity = draft.Quantity,
|
||||||
categoryId = draft.CategoryId,
|
categoryId = draft.CategoryId,
|
||||||
listingDescription = draft.Description,
|
listingDescription = draft.Description,
|
||||||
listingPolicies,
|
listingPolicies = new
|
||||||
pricingSummary = new
|
|
||||||
{
|
{
|
||||||
price = new { value = draft.Price.ToString("F2"), currency = "GBP" }
|
fulfillmentPolicyId = _fulfillmentPolicyId,
|
||||||
|
paymentPolicyId = _paymentPolicyId,
|
||||||
|
returnPolicyId = _returnPolicyId
|
||||||
},
|
},
|
||||||
merchantLocationKey = "home",
|
pricingSummary = new { price = new { value = draft.Price.ToString("F2"), currency = "GBP" } },
|
||||||
|
merchantLocationKey = _merchantLocationKey,
|
||||||
tax = new { vatPercentage = 0, applyTax = false }
|
tax = new { vatPercentage = 0, applyTax = false }
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonConvert.SerializeObject(offer, new JsonSerializerSettings
|
using var req = MakeRequest(HttpMethod.Post,
|
||||||
{
|
$"{_auth.BaseUrl}/sell/inventory/v1/offer", token);
|
||||||
NullValueHandling = NullValueHandling.Ignore
|
req.Content = new StringContent(JsonConvert.SerializeObject(offer, _jsonSettings), Encoding.UTF8, "application/json");
|
||||||
});
|
|
||||||
|
|
||||||
var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer";
|
var res = await _http.SendAsync(req);
|
||||||
var response = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json"));
|
var responseJson = await res.Content.ReadAsStringAsync();
|
||||||
var responseJson = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!res.IsSuccessStatusCode)
|
||||||
throw new HttpRequestException($"Failed to create offer: {responseJson}");
|
throw new HttpRequestException($"Failed to create offer: {responseJson}");
|
||||||
|
|
||||||
var obj = JObject.Parse(responseJson);
|
return JObject.Parse(responseJson)["offerId"]?.ToString()
|
||||||
return obj["offerId"]?.ToString()
|
|
||||||
?? throw new InvalidOperationException("No offerId in create offer response.");
|
?? throw new InvalidOperationException("No offerId in create offer response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Publish ----
|
||||||
|
|
||||||
private async Task<string> PublishOfferAsync(string offerId, string token)
|
private async Task<string> PublishOfferAsync(string offerId, string token)
|
||||||
{
|
{
|
||||||
using var http = BuildClient(token);
|
using var req = MakeRequest(HttpMethod.Post,
|
||||||
var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish";
|
$"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish", token);
|
||||||
var response = await http.PostAsync(url, new StringContent("{}", Encoding.UTF8, "application/json"));
|
req.Content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||||
var responseJson = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
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}");
|
throw new HttpRequestException($"Failed to publish offer: {responseJson}");
|
||||||
|
|
||||||
var obj = JObject.Parse(responseJson);
|
return JObject.Parse(responseJson)["listingId"]?.ToString()
|
||||||
return obj["listingId"]?.ToString()
|
|
||||||
?? throw new InvalidOperationException("No listingId in publish response.");
|
?? throw new InvalidOperationException("No listingId in publish response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Photo upload ----
|
||||||
|
|
||||||
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
|
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
|
||||||
{
|
{
|
||||||
var urls = new List<string>();
|
if (photoPaths.Count == 0) return [];
|
||||||
if (photoPaths.Count == 0) return urls;
|
|
||||||
|
|
||||||
// Use Trading API UploadSiteHostedPictures for each photo
|
|
||||||
var tradingBase = _auth.BaseUrl.Contains("sandbox")
|
var tradingBase = _auth.BaseUrl.Contains("sandbox")
|
||||||
? "https://api.sandbox.ebay.com/ws/api.dll"
|
? "https://api.sandbox.ebay.com/ws/api.dll"
|
||||||
: "https://api.ebay.com/ws/api.dll";
|
: "https://api.ebay.com/ws/api.dll";
|
||||||
|
|
||||||
foreach (var path in photoPaths.Take(12))
|
var semaphore = new SemaphoreSlim(4);
|
||||||
|
var tasks = photoPaths.Take(12).Select(async path =>
|
||||||
{
|
{
|
||||||
if (!File.Exists(path)) continue;
|
await semaphore.WaitAsync();
|
||||||
|
try { return await UploadSinglePhotoAsync(path, tradingBase, token); }
|
||||||
|
catch { return null; }
|
||||||
|
finally { semaphore.Release(); }
|
||||||
|
});
|
||||||
|
|
||||||
try
|
return [.. (await Task.WhenAll(tasks)).Where(u => !string.IsNullOrEmpty(u))];
|
||||||
{
|
|
||||||
var url = await UploadSinglePhotoAsync(path, tradingBase, token);
|
|
||||||
if (!string.IsNullOrEmpty(url))
|
|
||||||
urls.Add(url);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Skip failed photo uploads, don't abort the whole listing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return urls;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
|
private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
|
||||||
{
|
{
|
||||||
var fileBytes = await File.ReadAllBytesAsync(filePath);
|
var fileBytes = await File.ReadAllBytesAsync(filePath);
|
||||||
var base64 = Convert.ToBase64String(fileBytes);
|
var ext = Path.GetExtension(filePath).TrimStart('.').ToLower();
|
||||||
var ext = Path.GetExtension(filePath).TrimStart('.').ToUpper();
|
|
||||||
|
|
||||||
var soapBody = $"""
|
var soapBody = $"""
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
@@ -197,78 +422,37 @@ public class EbayListingService
|
|||||||
</RequesterCredentials>
|
</RequesterCredentials>
|
||||||
<PictureName>{Path.GetFileNameWithoutExtension(filePath)}</PictureName>
|
<PictureName>{Path.GetFileNameWithoutExtension(filePath)}</PictureName>
|
||||||
<PictureSet>Supersize</PictureSet>
|
<PictureSet>Supersize</PictureSet>
|
||||||
<ExternalPictureURL>https://example.com/placeholder.jpg</ExternalPictureURL>
|
|
||||||
</UploadSiteHostedPicturesRequest>
|
</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);
|
|
||||||
|
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
|
content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
|
||||||
var imageContent = new ByteArrayContent(fileBytes);
|
var imageContent = new ByteArrayContent(fileBytes);
|
||||||
imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext.ToLower()}");
|
imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext}");
|
||||||
content.Add(imageContent, "dummy", Path.GetFileName(filePath));
|
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();
|
var responseXml = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// Parse URL from XML response
|
|
||||||
var match = System.Text.RegularExpressions.Regex.Match(
|
var match = System.Text.RegularExpressions.Regex.Match(
|
||||||
responseXml, @"<FullURL>(.*?)</FullURL>");
|
responseXml, @"<FullURL>(.*?)</FullURL>");
|
||||||
return match.Success ? match.Groups[1].Value : null;
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JObject BuildListingPolicies(ListingDraft draft)
|
// ---- Helpers ----
|
||||||
{
|
|
||||||
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")
|
|
||||||
};
|
|
||||||
|
|
||||||
return new JObject
|
private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
|
||||||
{
|
{
|
||||||
["shippingPolicyName"] = "Default",
|
var req = new HttpRequestMessage(method, url);
|
||||||
["paymentPolicyName"] = "Default",
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
["returnPolicyName"] = "Default",
|
req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
|
||||||
["shippingCostType"] = "FLAT_RATE",
|
return req;
|
||||||
["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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 \u00A3{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}: \u00A3{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: \u00A3{price:F2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* AI unavailable */ }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using EbayListingTool.Models;
|
using EbayListingTool.Models;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace EbayListingTool.Services;
|
namespace EbayListingTool.Services;
|
||||||
@@ -35,7 +35,11 @@ public class SavedListingsService
|
|||||||
public (SavedListing Listing, int SkippedPhotos) Save(
|
public (SavedListing Listing, int SkippedPhotos) Save(
|
||||||
string title, string description, decimal price,
|
string title, string description, decimal price,
|
||||||
string category, string conditionNotes,
|
string category, string conditionNotes,
|
||||||
IEnumerable<string> sourcePaths)
|
IEnumerable<string> sourcePaths,
|
||||||
|
string categoryId = "",
|
||||||
|
ItemCondition condition = ItemCondition.Used,
|
||||||
|
ListingFormat format = ListingFormat.FixedPrice,
|
||||||
|
string postcode = "")
|
||||||
{
|
{
|
||||||
var safeName = MakeSafeFilename(title);
|
var safeName = MakeSafeFilename(title);
|
||||||
var exportDir = UniqueDir(Path.Combine(ExportsDir, safeName));
|
var exportDir = UniqueDir(Path.Combine(ExportsDir, safeName));
|
||||||
@@ -68,6 +72,10 @@ public class SavedListingsService
|
|||||||
Description = description,
|
Description = description,
|
||||||
Price = price,
|
Price = price,
|
||||||
Category = category,
|
Category = category,
|
||||||
|
CategoryId = categoryId,
|
||||||
|
Condition = condition,
|
||||||
|
Format = format,
|
||||||
|
Postcode = postcode,
|
||||||
ConditionNotes = conditionNotes,
|
ConditionNotes = conditionNotes,
|
||||||
ExportFolder = exportDir,
|
ExportFolder = exportDir,
|
||||||
PhotoPaths = photoPaths
|
PhotoPaths = photoPaths
|
||||||
@@ -91,6 +99,56 @@ public class SavedListingsService
|
|||||||
catch { /* ignore — user may have already deleted it */ }
|
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
|
// S3: use ProcessStartInfo with FileName so spaces/special chars are handled correctly
|
||||||
public void OpenExportFolder(SavedListing listing)
|
public void OpenExportFolder(SavedListing listing)
|
||||||
{
|
{
|
||||||
@@ -138,7 +196,7 @@ public class SavedListingsService
|
|||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
sb.AppendLine($"Title: {title}");
|
sb.AppendLine($"Title: {title}");
|
||||||
sb.AppendLine($"Category: {category}");
|
sb.AppendLine($"Category: {category}");
|
||||||
sb.AppendLine($"Price: £{price:F2}");
|
sb.AppendLine($"Price: \u00A3{price:F2}");
|
||||||
if (!string.IsNullOrWhiteSpace(conditionNotes))
|
if (!string.IsNullOrWhiteSpace(conditionNotes))
|
||||||
sb.AppendLine($"Condition: {conditionNotes}");
|
sb.AppendLine($"Condition: {conditionNotes}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|||||||
11
EbayListingTool/Views/BulkImportWindow.xaml
Normal file
11
EbayListingTool/Views/BulkImportWindow.xaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<mah:MetroWindow x:Class="EbayListingTool.Views.BulkImportWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
||||||
|
xmlns:local="clr-namespace:EbayListingTool.Views"
|
||||||
|
Title="Bulk Import - eBay Listing Tool"
|
||||||
|
Height="700" Width="1000"
|
||||||
|
MinHeight="500" MinWidth="700"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
<local:BulkImportView x:Name="BulkView" />
|
||||||
|
</mah:MetroWindow>
|
||||||
18
EbayListingTool/Views/BulkImportWindow.xaml.cs
Normal file
18
EbayListingTool/Views/BulkImportWindow.xaml.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using EbayListingTool.Services;
|
||||||
|
using MahApps.Metro.Controls;
|
||||||
|
|
||||||
|
namespace EbayListingTool.Views;
|
||||||
|
|
||||||
|
public partial class BulkImportWindow : MetroWindow
|
||||||
|
{
|
||||||
|
public BulkImportWindow(
|
||||||
|
EbayListingService listingService,
|
||||||
|
EbayCategoryService categoryService,
|
||||||
|
AiAssistantService aiService,
|
||||||
|
BulkImportService bulkService,
|
||||||
|
EbayAuthService auth)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
BulkView.Initialise(listingService, categoryService, aiService, bulkService, auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<mah:MetroWindow x:Class="EbayListingTool.Views.MainWindow"
|
<mah:MetroWindow x:Class="EbayListingTool.Views.MainWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
||||||
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
|
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
|
||||||
xmlns:local="clr-namespace:EbayListingTool.Views"
|
xmlns:local="clr-namespace:EbayListingTool.Views"
|
||||||
Title="eBay Listing Tool — UK"
|
Title="eBay Listing Tool - UK"
|
||||||
Height="820" Width="1180"
|
Height="820" Width="1180"
|
||||||
MinHeight="600" MinWidth="900"
|
MinHeight="600" MinWidth="900"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Icon="/EbayListingTool;component/app_icon.ico"
|
||||||
GlowBrush="{DynamicResource MahApps.Brushes.Accent}">
|
GlowBrush="{DynamicResource MahApps.Brushes.Accent}">
|
||||||
|
|
||||||
<mah:MetroWindow.Resources>
|
<mah:MetroWindow.Resources>
|
||||||
@@ -59,110 +60,62 @@
|
|||||||
</EventTrigger>
|
</EventTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for tab header icon -->
|
||||||
|
<Style x:Key="TabHeaderIcon" TargetType="iconPacks:PackIconMaterial">
|
||||||
|
<Setter Property="Width" Value="15"/>
|
||||||
|
<Setter Property="Height" Value="15"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,7,0"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
</mah:MetroWindow.Resources>
|
</mah:MetroWindow.Resources>
|
||||||
|
|
||||||
<mah:MetroWindow.RightWindowCommands>
|
|
||||||
<mah:WindowCommands>
|
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,8,0">
|
|
||||||
<Border CornerRadius="10" Padding="8,3" Margin="0,0,8,0"
|
|
||||||
Background="#22FFFFFF" VerticalAlignment="Center">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<Ellipse x:Name="StatusDot" Style="{StaticResource ConnectedDotStyle}" Fill="#777"/>
|
|
||||||
<TextBlock x:Name="StatusLabel" Text="eBay: not connected"
|
|
||||||
Foreground="White" VerticalAlignment="Center"
|
|
||||||
FontSize="11" FontWeight="SemiBold"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Button x:Name="ConnectBtn" Click="ConnectBtn_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
|
||||||
Height="28" Padding="10,0">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Link" Width="12" Height="12"
|
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Connect to eBay" VerticalAlignment="Center" FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button x:Name="DisconnectBtn" Visibility="Collapsed"
|
|
||||||
Margin="6,0,0,0" Click="DisconnectBtn_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="28" Padding="8,0">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="LinkVariantOff" Width="12" Height="12"
|
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Disconnect" VerticalAlignment="Center" FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</mah:WindowCommands>
|
|
||||||
</mah:MetroWindow.RightWindowCommands>
|
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TabControl x:Name="MainTabs" Grid.Row="0"
|
<!-- Menu bar -->
|
||||||
|
<Menu Grid.Row="0"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}">
|
||||||
|
<MenuItem Header="_File">
|
||||||
|
<MenuItem x:Name="BulkImportMenuItem" Header="Bulk Import..."
|
||||||
|
Click="BulkImport_Click">
|
||||||
|
<MenuItem.Icon>
|
||||||
|
<iconPacks:PackIconMaterial Kind="TableMultiple" Width="14" Height="14"/>
|
||||||
|
</MenuItem.Icon>
|
||||||
|
</MenuItem>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="E_xit" Click="Exit_Click"/>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<!-- 2 tabs -->
|
||||||
|
<TabControl x:Name="MainTabs" Grid.Row="1"
|
||||||
Style="{DynamicResource MahApps.Styles.TabControl.Animated}">
|
Style="{DynamicResource MahApps.Styles.TabControl.Animated}">
|
||||||
|
|
||||||
<!-- ① Photo Analysis — always available, no eBay login needed -->
|
<!-- New Listing tab -->
|
||||||
<TabItem Style="{StaticResource AppTabItem}">
|
|
||||||
<TabItem.Header>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Camera" Width="15" Height="15"
|
|
||||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Photo Analyser" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</TabItem.Header>
|
|
||||||
<!-- Tab content: welcome banner + actual view stacked -->
|
|
||||||
<Grid>
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- Welcome banner — only shown when no photo loaded yet (PhotoView sets Visibility via x:Name) -->
|
|
||||||
<Border x:Name="WelcomeBanner" Grid.Row="0"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
Padding="14,7" Visibility="Visible">
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Camera" Width="14" Height="14"
|
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
|
||||||
Foreground="White"/>
|
|
||||||
<TextBlock Text="Drop a photo to identify any item and get an instant eBay price"
|
|
||||||
Foreground="White" FontSize="12" FontWeight="SemiBold"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<local:PhotoAnalysisView x:Name="PhotoView" Grid.Row="1"/>
|
|
||||||
</Grid>
|
|
||||||
</TabItem>
|
|
||||||
|
|
||||||
<!-- ② New Listing — requires eBay connection -->
|
|
||||||
<TabItem x:Name="NewListingTab" Style="{StaticResource AppTabItem}">
|
<TabItem x:Name="NewListingTab" Style="{StaticResource AppTabItem}">
|
||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="TagPlusOutline" Width="15" Height="15"
|
<iconPacks:PackIconMaterial Kind="CameraPlus"
|
||||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
Style="{StaticResource TabHeaderIcon}"/>
|
||||||
<TextBlock Text="New Listing" VerticalAlignment="Center"/>
|
<TextBlock Text="New Listing" VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
<Grid>
|
<Grid>
|
||||||
<local:SingleItemView x:Name="SingleView"/>
|
<local:NewListingView x:Name="NewListingView"/>
|
||||||
<!-- Overlay shown when not connected -->
|
<!-- Overlay when not connected to eBay -->
|
||||||
<Border x:Name="NewListingOverlay" Visibility="Visible">
|
<Border x:Name="NewListingOverlay" Visibility="Visible"
|
||||||
<Border.Background>
|
Background="{DynamicResource MahApps.Brushes.ThemeBackground}">
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="340">
|
||||||
<GradientStop Color="#F0F4FF" Offset="0"/>
|
|
||||||
<GradientStop Color="#EEF2FF" Offset="1"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Border.Background>
|
|
||||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
|
||||||
MaxWidth="340">
|
|
||||||
<!-- eBay logo circle -->
|
|
||||||
<Border Width="72" Height="72" CornerRadius="36"
|
<Border Width="72" Height="72" CornerRadius="36"
|
||||||
HorizontalAlignment="Center" Margin="0,0,0,18">
|
HorizontalAlignment="Center" Margin="0,0,0,18">
|
||||||
|
|
||||||
<Border.Background>
|
<Border.Background>
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
<GradientStop Color="#7C3AED" Offset="0"/>
|
||||||
@@ -172,20 +125,21 @@
|
|||||||
<iconPacks:PackIconMaterial Kind="CartOutline" Width="32" Height="32"
|
<iconPacks:PackIconMaterial Kind="CartOutline" Width="32" Height="32"
|
||||||
Foreground="White"
|
Foreground="White"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"
|
||||||
|
/>
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Text="Connect to eBay"
|
<TextBlock Text="Connect to eBay" FontSize="20" FontWeight="Bold"
|
||||||
FontSize="20" FontWeight="Bold"
|
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"
|
Foreground="{DynamicResource MahApps.Brushes.ThemeForeground}"
|
||||||
Margin="0,0,0,8"/>
|
Margin="0,0,0,8"/>
|
||||||
<TextBlock Text="Sign in with your eBay account to start posting listings and managing your inventory."
|
<TextBlock Text="Sign in with your eBay account to identify items, get prices, and post listings."
|
||||||
FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
|
FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
Margin="0,0,0,24"/>
|
Margin="0,0,0,24"/>
|
||||||
<Button Click="ConnectBtn_Click"
|
<Button x:Name="ConnectBtn" Click="ConnectBtn_Click"
|
||||||
Style="{StaticResource LockConnectButton}"
|
Style="{StaticResource LockConnectButton}"
|
||||||
HorizontalAlignment="Center">
|
HorizontalAlignment="Center"
|
||||||
|
AutomationProperties.Name="Connect to eBay account">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14"
|
<iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14"
|
||||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||||
@@ -197,78 +151,21 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<!-- ③ Saved Listings — always available -->
|
<!-- Drafts tab -->
|
||||||
<TabItem x:Name="SavedTab" Style="{StaticResource AppTabItem}">
|
<TabItem x:Name="DraftsTab" Style="{StaticResource AppTabItem}">
|
||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="15" Height="15"
|
<iconPacks:PackIconMaterial Kind="BookmarkMultiple"
|
||||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
Style="{StaticResource TabHeaderIcon}"/>
|
||||||
<TextBlock Text="Saved Listings" VerticalAlignment="Center"/>
|
<TextBlock Text="Drafts" VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
<local:SavedListingsView x:Name="SavedView"/>
|
<local:SavedListingsView x:Name="SavedView"/>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<!-- ④ Bulk Import — requires eBay connection -->
|
|
||||||
<TabItem x:Name="BulkTab" Style="{StaticResource AppTabItem}">
|
|
||||||
<TabItem.Header>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="TableMultiple" Width="15" Height="15"
|
|
||||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Bulk Import" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</TabItem.Header>
|
|
||||||
<Grid>
|
|
||||||
<local:BulkImportView x:Name="BulkView"/>
|
|
||||||
<Border x:Name="BulkOverlay" Visibility="Visible">
|
|
||||||
<Border.Background>
|
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
|
|
||||||
<GradientStop Color="#F0F4FF" Offset="0"/>
|
|
||||||
<GradientStop Color="#EEF2FF" Offset="1"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Border.Background>
|
|
||||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
|
||||||
MaxWidth="340">
|
|
||||||
<!-- eBay logo circle -->
|
|
||||||
<Border Width="72" Height="72" CornerRadius="36"
|
|
||||||
HorizontalAlignment="Center" Margin="0,0,0,18">
|
|
||||||
<Border.Background>
|
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
|
||||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
|
||||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Border.Background>
|
|
||||||
<iconPacks:PackIconMaterial Kind="TableArrowUp" Width="32" Height="32"
|
|
||||||
Foreground="White"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</Border>
|
|
||||||
<TextBlock Text="Connect to eBay"
|
|
||||||
FontSize="20" FontWeight="Bold"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"
|
|
||||||
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}"
|
|
||||||
Margin="0,0,0,24"/>
|
|
||||||
<Button Click="ConnectBtn_Click"
|
|
||||||
Style="{StaticResource LockConnectButton}"
|
|
||||||
HorizontalAlignment="Center">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Link" Width="14" Height="14"
|
|
||||||
Margin="0,0,7,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Connect to eBay" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</TabItem>
|
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
<Border Grid.Row="1"
|
<Border Grid.Row="2"
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
||||||
BorderThickness="0,1,0,0"
|
BorderThickness="0,1,0,0"
|
||||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}">
|
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}">
|
||||||
@@ -281,9 +178,10 @@
|
|||||||
<iconPacks:PackIconMaterial Kind="AlertCircleOutline"
|
<iconPacks:PackIconMaterial Kind="AlertCircleOutline"
|
||||||
Width="12" Height="12" Margin="0,0,5,0"
|
Width="12" Height="12" Margin="0,0,5,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
/>
|
||||||
<TextBlock x:Name="StatusBar" Text="Ready" FontSize="11"
|
<TextBlock x:Name="StatusBar" Text="Ready" FontSize="11"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||||
@@ -291,7 +189,15 @@
|
|||||||
Fill="#888" Margin="0,0,5,0" VerticalAlignment="Center"/>
|
Fill="#888" Margin="0,0,5,0" VerticalAlignment="Center"/>
|
||||||
<TextBlock x:Name="StatusBarEbay" Text="eBay: disconnected"
|
<TextBlock x:Name="StatusBarEbay" Text="eBay: disconnected"
|
||||||
FontSize="11" VerticalAlignment="Center"
|
FontSize="11" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||||
|
<!-- Disconnect button shown when connected -->
|
||||||
|
<Button x:Name="DisconnectBtn" Click="DisconnectBtn_Click"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Style="{StaticResource MahApps.Styles.Button.Square}"
|
||||||
|
Padding="6,2" Margin="8,0,0,0" FontSize="10"
|
||||||
|
AutomationProperties.Name="Disconnect from eBay">
|
||||||
|
<TextBlock Text="Disconnect"/>
|
||||||
|
</Button>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public partial class MainWindow : MetroWindow
|
|||||||
private readonly BulkImportService _bulkService;
|
private readonly BulkImportService _bulkService;
|
||||||
private readonly SavedListingsService _savedService;
|
private readonly SavedListingsService _savedService;
|
||||||
private readonly EbayPriceResearchService _priceService;
|
private readonly EbayPriceResearchService _priceService;
|
||||||
|
private readonly PriceLookupService _priceLookupService;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -28,19 +29,15 @@ public partial class MainWindow : MetroWindow
|
|||||||
_bulkService = new BulkImportService();
|
_bulkService = new BulkImportService();
|
||||||
_savedService = new SavedListingsService();
|
_savedService = new SavedListingsService();
|
||||||
_priceService = new EbayPriceResearchService(_auth);
|
_priceService = new EbayPriceResearchService(_auth);
|
||||||
|
_priceLookupService = new PriceLookupService(_priceService, _savedService, _aiService);
|
||||||
|
|
||||||
// Photo Analysis tab — no eBay needed
|
var defaultPostcode = config["Ebay:DefaultPostcode"] ?? "";
|
||||||
PhotoView.Initialise(_aiService, _savedService, _priceService);
|
|
||||||
PhotoView.UseDetailsRequested += OnUseDetailsRequested;
|
|
||||||
|
|
||||||
// Saved Listings tab
|
NewListingView.Initialise(_listingService, _categoryService, _aiService, _auth,
|
||||||
SavedView.Initialise(_savedService);
|
_savedService, defaultPostcode);
|
||||||
|
|
||||||
// New Listing + Bulk tabs
|
SavedView.Initialise(_savedService, _priceLookupService, _listingService, _auth);
|
||||||
SingleView.Initialise(_listingService, _categoryService, _aiService, _auth);
|
|
||||||
BulkView.Initialise(_listingService, _categoryService, _aiService, _bulkService, _auth);
|
|
||||||
|
|
||||||
// Try to restore saved eBay session
|
|
||||||
_auth.TryLoadSavedToken();
|
_auth.TryLoadSavedToken();
|
||||||
UpdateConnectionState();
|
UpdateConnectionState();
|
||||||
}
|
}
|
||||||
@@ -50,7 +47,7 @@ public partial class MainWindow : MetroWindow
|
|||||||
private async void ConnectBtn_Click(object sender, RoutedEventArgs e)
|
private async void ConnectBtn_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
ConnectBtn.IsEnabled = false;
|
ConnectBtn.IsEnabled = false;
|
||||||
SetStatus("Connecting to eBay…");
|
SetStatus("Connecting to eBay...");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var username = await _auth.LoginAsync();
|
var username = await _auth.LoginAsync();
|
||||||
@@ -63,12 +60,17 @@ public partial class MainWindow : MetroWindow
|
|||||||
MessageBox.Show(ex.Message, "eBay Login Failed",
|
MessageBox.Show(ex.Message, "eBay Login Failed",
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
}
|
}
|
||||||
finally { ConnectBtn.IsEnabled = true; }
|
finally
|
||||||
|
{
|
||||||
|
ConnectBtn.IsEnabled = true;
|
||||||
|
UpdateConnectionState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
|
private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_auth.Disconnect();
|
_auth.Disconnect();
|
||||||
|
_listingService.ClearCache();
|
||||||
UpdateConnectionState();
|
UpdateConnectionState();
|
||||||
SetStatus("Disconnected from eBay.");
|
SetStatus("Disconnected from eBay.");
|
||||||
}
|
}
|
||||||
@@ -76,50 +78,48 @@ public partial class MainWindow : MetroWindow
|
|||||||
private void UpdateConnectionState()
|
private void UpdateConnectionState()
|
||||||
{
|
{
|
||||||
var connected = _auth.IsConnected;
|
var connected = _auth.IsConnected;
|
||||||
|
|
||||||
// Per-tab overlays (Photo Analysis tab has no overlay)
|
|
||||||
NewListingOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
|
NewListingOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
|
||||||
BulkOverlay.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
|
|
||||||
ConnectBtn.Visibility = connected ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
DisconnectBtn.Visibility = connected ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
|
|
||||||
if (connected)
|
if (connected)
|
||||||
{
|
{
|
||||||
StatusDot.Fill = new SolidColorBrush(Colors.LimeGreen);
|
|
||||||
StatusLabel.Text = $"eBay: {_auth.ConnectedUsername}";
|
|
||||||
StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen);
|
StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen);
|
||||||
StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}";
|
StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}";
|
||||||
StatusBarEbay.Foreground = new SolidColorBrush(Colors.LimeGreen);
|
StatusBarEbay.Foreground = new SolidColorBrush(Colors.LimeGreen);
|
||||||
|
DisconnectBtn.Visibility = Visibility.Visible;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
StatusDot.Fill = new SolidColorBrush(Colors.Gray);
|
|
||||||
StatusLabel.Text = "eBay: not connected";
|
|
||||||
StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
|
StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
|
||||||
StatusBarEbay.Text = "eBay: disconnected";
|
StatusBarEbay.Text = "eBay: disconnected";
|
||||||
StatusBarEbay.Foreground = (Brush)FindResource("MahApps.Brushes.Gray5");
|
StatusBarEbay.Foreground = (Brush)FindResource("MahApps.Brushes.Gray5");
|
||||||
|
DisconnectBtn.Visibility = Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Photo Analysis → New Listing handoff ----
|
// ---- File menu ----
|
||||||
|
|
||||||
private void OnUseDetailsRequested(PhotoAnalysisResult result, IReadOnlyList<string> photoPaths, decimal price)
|
private void BulkImport_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
SingleView.PopulateFromAnalysis(result, photoPaths, price); // Q1: forward all photos
|
if (!_auth.IsConnected)
|
||||||
}
|
|
||||||
|
|
||||||
public void SwitchToNewListingTab()
|
|
||||||
{
|
{
|
||||||
MainTabs.SelectedItem = NewListingTab;
|
MessageBox.Show("Please connect to eBay before using Bulk Import.",
|
||||||
|
"Not Connected", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var win = new BulkImportWindow(_listingService, _categoryService, _aiService, _bulkService, _auth);
|
||||||
|
win.Owner = this;
|
||||||
|
win.ShowDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RefreshSavedListings()
|
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
|
||||||
{
|
|
||||||
SavedView.RefreshList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Public interface for child views ----
|
||||||
|
|
||||||
public void SetStatus(string message) => StatusBar.Text = message;
|
public void SetStatus(string message) => StatusBar.Text = message;
|
||||||
|
|
||||||
|
public void SwitchToNewListingTab() => MainTabs.SelectedItem = NewListingTab;
|
||||||
|
|
||||||
|
public void RefreshDrafts() => SavedView.RefreshList();
|
||||||
|
|
||||||
|
public void RefreshSavedListings() => RefreshDrafts(); // backwards compat for NewListingView
|
||||||
}
|
}
|
||||||
|
|||||||
538
EbayListingTool/Views/NewListingView.xaml
Normal file
538
EbayListingTool/Views/NewListingView.xaml
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
<UserControl x:Class="EbayListingTool.Views.NewListingView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
||||||
|
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
|
||||||
|
Loaded="UserControl_Loaded">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<!-- Shared style for AI action buttons (Title AI, Desc AI, Price Research) -->
|
||||||
|
<Style x:Key="AiActionButton" TargetType="Button"
|
||||||
|
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||||
|
<Setter Property="Padding" Value="6,2"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for field labels -->
|
||||||
|
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray3}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for section headers (PHOTOS, LISTING DETAILS) -->
|
||||||
|
<Style x:Key="SectionHeader" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for subtitle/hint text -->
|
||||||
|
<Style x:Key="HintText" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for character count labels -->
|
||||||
|
<Style x:Key="CharCountLabel" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Margin" Value="6,0,0,0"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for progress track background -->
|
||||||
|
<Style x:Key="ProgressTrack" TargetType="Border">
|
||||||
|
<Setter Property="Height" Value="3"/>
|
||||||
|
<Setter Property="CornerRadius" Value="1.5"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for progress track fill -->
|
||||||
|
<Style x:Key="ProgressFill" TargetType="Border">
|
||||||
|
<Setter Property="Height" Value="3"/>
|
||||||
|
<Setter Property="CornerRadius" Value="1.5"/>
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||||
|
<Setter Property="Width" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<!-- Root grid hosts all three states; Visibility toggled in code-behind -->
|
||||||
|
<Grid>
|
||||||
|
<!-- STATE A: Drop Zone -->
|
||||||
|
<Grid x:Name="StateA" Visibility="Visible">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
|
||||||
|
<!-- Loading panel - shown while AI runs -->
|
||||||
|
<Border x:Name="LoadingPanel" DockPanel.Dock="Top"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Margin="60,30,60,0" Padding="30,40"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
||||||
|
CornerRadius="10">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<mah:ProgressRing Width="36" Height="36"
|
||||||
|
IsTabStop="False"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,16"/>
|
||||||
|
<TextBlock x:Name="LoadingStepText"
|
||||||
|
Text="Examining the photo."
|
||||||
|
FontSize="14" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
|
||||||
|
<TextBlock Text="This usually takes 10-20 seconds"
|
||||||
|
Style="{StaticResource HintText}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,6,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Drop zone -->
|
||||||
|
<Border x:Name="DropZoneBorder" DockPanel.Dock="Top"
|
||||||
|
Margin="60,30,60,0"
|
||||||
|
AllowDrop="True"
|
||||||
|
Focusable="True"
|
||||||
|
MouseLeftButtonUp="DropZone_Click"
|
||||||
|
DragOver="DropZone_DragOver"
|
||||||
|
DragEnter="DropZone_DragEnter"
|
||||||
|
DragLeave="DropZone_DragLeave"
|
||||||
|
Drop="DropZone_Drop"
|
||||||
|
Cursor="Hand"
|
||||||
|
MinHeight="180"
|
||||||
|
AutomationProperties.Name="Photo drop zone - drop photos here or click to browse">
|
||||||
|
<Grid Background="Transparent">
|
||||||
|
<!-- Dashed border via Rectangle -->
|
||||||
|
<Rectangle x:Name="DropBorderRect"
|
||||||
|
StrokeThickness="2"
|
||||||
|
StrokeDashArray="6,4"
|
||||||
|
RadiusX="10" RadiusY="10"
|
||||||
|
Stroke="{DynamicResource MahApps.Brushes.Gray6}"
|
||||||
|
/>
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Margin="0,40"
|
||||||
|
IsHitTestVisible="False">
|
||||||
|
<iconPacks:PackIconMaterial Kind="CameraOutline"
|
||||||
|
Width="52" Height="52"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
Margin="0,0,0,16"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock Text="Drop photos here"
|
||||||
|
FontSize="18" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
|
||||||
|
<TextBlock Text="or click to browse - up to 12 photos"
|
||||||
|
Style="{StaticResource HintText}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,6,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Thumbnail strip -->
|
||||||
|
<ScrollViewer x:Name="ThumbScroller" DockPanel.Dock="Top"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Disabled"
|
||||||
|
Focusable="False"
|
||||||
|
Margin="60,12,60,0" Visibility="Collapsed">
|
||||||
|
<StackPanel x:Name="ThumbStrip" Orientation="Horizontal"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Analyse button -->
|
||||||
|
<StackPanel DockPanel.Dock="Top" HorizontalAlignment="Center" Margin="0,20,0,0">
|
||||||
|
<Button x:Name="AnalyseBtn"
|
||||||
|
Click="Analyse_Click"
|
||||||
|
IsEnabled="False"
|
||||||
|
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
|
||||||
|
Padding="28,12" FontSize="14" FontWeight="SemiBold"
|
||||||
|
AutomationProperties.Name="Identify and price item with AI">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial x:Name="AnalyseIcon"
|
||||||
|
Kind="MagnifyScan" Width="18" Height="18"
|
||||||
|
Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||||
|
<mah:ProgressRing x:Name="AnalyseSpinner"
|
||||||
|
Width="18" Height="18" Margin="0,0,8,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock x:Name="AnalyseBtnText"
|
||||||
|
Text="Identify & Price with AI"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<TextBlock x:Name="PhotoCountLabel"
|
||||||
|
HorizontalAlignment="Center" Margin="0,8,0,0"
|
||||||
|
FontSize="13" Visibility="Collapsed"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid/> <!-- fill remaining space -->
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- STATE B: Review & Edit -->
|
||||||
|
<Grid x:Name="StateB" Visibility="Collapsed">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
|
||||||
|
<!-- Footer bar - pinned to bottom via DockPanel.Dock -->
|
||||||
|
<Border DockPanel.Dock="Bottom"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
|
||||||
|
Padding="16,8">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0" x:Name="StartOverBtn"
|
||||||
|
Click="StartOver_Click"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
Cursor="Hand" VerticalAlignment="Center"
|
||||||
|
AutomationProperties.Name="Start over and discard edits">
|
||||||
|
<TextBlock FontSize="13">
|
||||||
|
<Run Text="← "/>
|
||||||
|
<Run Text="Start Over" TextDecorations="Underline"/>
|
||||||
|
</TextBlock>
|
||||||
|
</Button>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||||
|
<Button x:Name="SaveDraftBtn"
|
||||||
|
Click="SaveDraft_Click"
|
||||||
|
Style="{StaticResource MahApps.Styles.Button.Square}"
|
||||||
|
Padding="16,8" Margin="0,0,8,0"
|
||||||
|
AutomationProperties.Name="Save listing as draft">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial Kind="ContentSaveOutline"
|
||||||
|
Width="14" Height="14" Margin="0,0,6,0"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Save as Draft" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button x:Name="PostBtn"
|
||||||
|
Click="Post_Click"
|
||||||
|
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
|
||||||
|
Padding="16,8"
|
||||||
|
AutomationProperties.Name="Post listing to eBay">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial x:Name="PostIcon"
|
||||||
|
Kind="CartArrowRight" Width="14" Height="14"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||||
|
<mah:ProgressRing x:Name="PostSpinner"
|
||||||
|
Width="14" Height="14" Margin="0,0,6,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Two-column content area -->
|
||||||
|
<Grid Margin="16,12,16,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="220" MinWidth="160"/>
|
||||||
|
<ColumnDefinition Width="12"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- LEFT: Photos panel -->
|
||||||
|
<DockPanel Grid.Column="0">
|
||||||
|
<TextBlock DockPanel.Dock="Top"
|
||||||
|
Text="PHOTOS" Style="{StaticResource SectionHeader}"
|
||||||
|
Margin="0,0,0,8"/>
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
|
||||||
|
<Button x:Name="AddMorePhotosBtn" Click="AddMorePhotos_Click"
|
||||||
|
Style="{StaticResource MahApps.Styles.Button.Square}"
|
||||||
|
Padding="8,4" FontSize="13"
|
||||||
|
AutomationProperties.Name="Add more photos to listing">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial Kind="Plus" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Add more" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<TextBlock x:Name="BPhotoCount"
|
||||||
|
Margin="8,0,0,0" VerticalAlignment="Center"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
|
Focusable="False">
|
||||||
|
<WrapPanel x:Name="BPhotosPanel"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- RIGHT: Listing fields -->
|
||||||
|
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto"
|
||||||
|
Focusable="False">
|
||||||
|
<StackPanel Margin="0,0,8,16" MaxWidth="600">
|
||||||
|
<TextBlock Text="LISTING DETAILS"
|
||||||
|
Style="{StaticResource SectionHeader}"
|
||||||
|
Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<Grid Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock x:Name="TitleLabel" Text="Title"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" x:Name="AiTitleBtn" Click="AiTitle_Click"
|
||||||
|
Style="{StaticResource AiActionButton}"
|
||||||
|
ToolTip="Improve title with AI"
|
||||||
|
AutomationProperties.Name="Improve title with AI">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial x:Name="TitleAiIcon"
|
||||||
|
Kind="AutoFix" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||||
|
<mah:ProgressRing x:Name="TitleSpinner" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" Visibility="Collapsed"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock Text="AI" FontSize="12" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<TextBox x:Name="BTitleBox" TextChanged="TitleBox_TextChanged"
|
||||||
|
MaxLength="80" Margin="0,0,0,2"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=TitleLabel}"/>
|
||||||
|
<Grid Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Style="{StaticResource ProgressTrack}">
|
||||||
|
<Border x:Name="BTitleBar" Style="{StaticResource ProgressFill}"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock x:Name="BTitleCount" Grid.Column="1"
|
||||||
|
Text="0 / 80" Style="{StaticResource CharCountLabel}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<Grid Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock x:Name="DescLabel" Text="Description"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" x:Name="AiDescBtn" Click="AiDesc_Click"
|
||||||
|
Style="{StaticResource AiActionButton}"
|
||||||
|
ToolTip="Write description with AI"
|
||||||
|
AutomationProperties.Name="Write description with AI">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial x:Name="DescAiIcon"
|
||||||
|
Kind="AutoFix" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||||
|
<mah:ProgressRing x:Name="DescSpinner" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" Visibility="Collapsed"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock Text="AI" FontSize="12" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<TextBox x:Name="BDescBox" TextChanged="DescBox_TextChanged"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
Height="110" VerticalScrollBarVisibility="Auto"
|
||||||
|
Margin="0,0,0,2"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=DescLabel}"/>
|
||||||
|
<Grid Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Style="{StaticResource ProgressTrack}">
|
||||||
|
<Border x:Name="BDescBar" Style="{StaticResource ProgressFill}"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Accent}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock x:Name="BDescCount" Grid.Column="1"
|
||||||
|
Text="0 / 2000" Style="{StaticResource CharCountLabel}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<TextBlock x:Name="CategoryLabel" Text="Category"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
<Grid Margin="0,0,0,2">
|
||||||
|
<TextBox x:Name="BCategoryBox"
|
||||||
|
TextChanged="CategoryBox_TextChanged"
|
||||||
|
KeyDown="CategoryBox_KeyDown"
|
||||||
|
mah:TextBoxHelper.Watermark="Type to search categories."
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=CategoryLabel}"/>
|
||||||
|
<ListBox x:Name="BCategoryList"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
SelectionChanged="CategoryList_SelectionChanged"
|
||||||
|
MaxHeight="160"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="0,32,0,0"
|
||||||
|
Panel.ZIndex="10"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||||
|
BorderBrush="{DynamicResource MahApps.Brushes.Gray6}"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
|
ScrollViewer.CanContentScroll="True"
|
||||||
|
AutomationProperties.Name="Category suggestions"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock x:Name="BCategoryIdLabel"
|
||||||
|
Text="(no category selected)"
|
||||||
|
FontSize="12" Margin="0,0,0,12"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
|
||||||
|
<!-- Condition + Format -->
|
||||||
|
<Grid Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="12"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock x:Name="ConditionLabel" Text="Condition"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
<ComboBox x:Name="BConditionBox"
|
||||||
|
SelectionChanged="ConditionBox_SelectionChanged"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=ConditionLabel}">
|
||||||
|
<ComboBoxItem Content="New" Tag="New"/>
|
||||||
|
<ComboBoxItem Content="Open Box" Tag="OpenBox"/>
|
||||||
|
<ComboBoxItem Content="Refurbished" Tag="Refurbished"/>
|
||||||
|
<ComboBoxItem Content="Used" Tag="Used" IsSelected="True"/>
|
||||||
|
<ComboBoxItem Content="For Parts" Tag="ForParts"/>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2">
|
||||||
|
<TextBlock x:Name="FormatLabel" Text="Format"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
<ComboBox x:Name="BFormatBox"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=FormatLabel}">
|
||||||
|
<ComboBoxItem Content="Fixed Price" IsSelected="True"/>
|
||||||
|
<ComboBoxItem Content="Auction"/>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<Grid Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock x:Name="PriceLabel" Text="Price"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" x:Name="AiPriceBtn" Click="AiPrice_Click"
|
||||||
|
Style="{StaticResource AiActionButton}"
|
||||||
|
ToolTip="Research live eBay price"
|
||||||
|
AutomationProperties.Name="Research live eBay price">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial x:Name="PriceAiIcon"
|
||||||
|
Kind="Magnify" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||||
|
<mah:ProgressRing x:Name="PriceSpinner" Width="12" Height="12"
|
||||||
|
Margin="0,0,4,0" Visibility="Collapsed"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock Text="Research" FontSize="12" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<mah:NumericUpDown x:Name="BPriceBox" ValueChanged="PriceBox_ValueChanged"
|
||||||
|
StringFormat="£{0:0.00}"
|
||||||
|
Minimum="0" Maximum="99999"
|
||||||
|
Interval="0.50"
|
||||||
|
Margin="0,0,0,4"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=PriceLabel}"/>
|
||||||
|
<TextBlock x:Name="BPriceHint"
|
||||||
|
FontSize="12" Margin="0,0,0,4"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
||||||
|
<TextBlock x:Name="BFeeLabel"
|
||||||
|
FontSize="12" Margin="0,0,0,12"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
AutomationProperties.Name="Estimated eBay listing fee"/>
|
||||||
|
|
||||||
|
<!-- Postage + Postcode -->
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="12"/>
|
||||||
|
<ColumnDefinition Width="120"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock x:Name="PostageLabel" Text="Postage"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
<ComboBox x:Name="BPostageBox" SelectionChanged="PostageBox_SelectionChanged"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=PostageLabel}">
|
||||||
|
<ComboBoxItem Content="Royal Mail 1st Class" Tag="RoyalMailFirstClass"/>
|
||||||
|
<ComboBoxItem Content="Royal Mail 2nd Class" Tag="RoyalMailSecondClass" IsSelected="True"/>
|
||||||
|
<ComboBoxItem Content="Royal Mail Tracked 24" Tag="RoyalMailTracked24"/>
|
||||||
|
<ComboBoxItem Content="Royal Mail Tracked 48" Tag="RoyalMailTracked48"/>
|
||||||
|
<ComboBoxItem Content="Collection Only" Tag="CollectionOnly"/>
|
||||||
|
<ComboBoxItem Content="Free Postage" Tag="FreePostage"/>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2">
|
||||||
|
<TextBlock x:Name="PostcodeLabel" Text="From postcode"
|
||||||
|
Style="{StaticResource FieldLabel}"
|
||||||
|
Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="BPostcodeBox"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=PostcodeLabel}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- STATE C: Success -->
|
||||||
|
<Grid x:Name="StateC" Visibility="Collapsed">
|
||||||
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="480">
|
||||||
|
<!-- Success banner -->
|
||||||
|
<Border Background="#1A4CAF50" BorderBrush="#4CAF50" BorderThickness="0,0,0,3"
|
||||||
|
CornerRadius="8" Padding="24,16" Margin="0,0,0,28">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||||
|
<iconPacks:PackIconMaterial Kind="CheckCircleOutline" Width="24" Height="24"
|
||||||
|
Foreground="#4CAF50" VerticalAlignment="Center" Margin="0,0,12,0"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock Text="Listed successfully!" FontSize="18" FontWeight="SemiBold"
|
||||||
|
Foreground="#4CAF50" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<!-- URL -->
|
||||||
|
<TextBlock Text="Your listing is live at:" FontSize="12"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,8"/>
|
||||||
|
<TextBlock x:Name="BSuccessUrl"
|
||||||
|
FontSize="13" TextDecorations="Underline"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||||
|
HorizontalAlignment="Center" Cursor="Hand" TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center" Margin="0,0,0,16"
|
||||||
|
MouseLeftButtonUp="SuccessUrl_Click"
|
||||||
|
AutomationProperties.Name="Listing URL - click to open"/>
|
||||||
|
<Button x:Name="CopyUrlBtn" Click="CopyUrl_Click"
|
||||||
|
Style="{StaticResource MahApps.Styles.Button.Square}"
|
||||||
|
HorizontalAlignment="Center" Padding="16,8" Margin="0,0,0,36"
|
||||||
|
AutomationProperties.Name="Copy listing URL to clipboard">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="13" Height="13"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Copy URL" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<!-- List Another -->
|
||||||
|
<Button Click="ListAnother_Click"
|
||||||
|
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
|
||||||
|
HorizontalAlignment="Center" Padding="24,12" FontSize="14"
|
||||||
|
AutomationProperties.Name="List another item">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial Kind="Plus" Width="16" Height="16"
|
||||||
|
Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="List Another Item" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
784
EbayListingTool/Views/NewListingView.xaml.cs
Normal file
784
EbayListingTool/Views/NewListingView.xaml.cs
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using EbayListingTool.Models;
|
||||||
|
using EbayListingTool.Services;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace EbayListingTool.Views;
|
||||||
|
|
||||||
|
public partial class NewListingView : UserControl
|
||||||
|
{
|
||||||
|
// Services (injected via Initialise)
|
||||||
|
private EbayListingService? _listingService;
|
||||||
|
private EbayCategoryService? _categoryService;
|
||||||
|
private AiAssistantService? _aiService;
|
||||||
|
private EbayAuthService? _auth;
|
||||||
|
private SavedListingsService? _savedService;
|
||||||
|
private string _defaultPostcode = "";
|
||||||
|
|
||||||
|
// State A — photos
|
||||||
|
private readonly List<string> _photoPaths = new();
|
||||||
|
private const int MaxPhotos = 12;
|
||||||
|
|
||||||
|
// State B — draft being edited
|
||||||
|
private ListingDraft _draft = new();
|
||||||
|
private PhotoAnalysisResult? _lastAnalysis;
|
||||||
|
private bool _suppressCategoryLookup;
|
||||||
|
private System.Threading.CancellationTokenSource? _categoryCts;
|
||||||
|
private string _suggestedPriceValue = "";
|
||||||
|
|
||||||
|
// Loading step cycling
|
||||||
|
private readonly DispatcherTimer _loadingTimer;
|
||||||
|
private int _loadingStep;
|
||||||
|
private static readonly string[] LoadingSteps =
|
||||||
|
[
|
||||||
|
"Examining the photo\u2026",
|
||||||
|
"Identifying the item\u2026",
|
||||||
|
"Researching eBay prices\u2026",
|
||||||
|
"Writing description\u2026"
|
||||||
|
];
|
||||||
|
|
||||||
|
public NewListingView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2.5) };
|
||||||
|
_loadingTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
_loadingStep = (_loadingStep + 1) % LoadingSteps.Length;
|
||||||
|
LoadingStepText.Text = LoadingSteps[_loadingStep];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UserControl_Loaded(object sender, RoutedEventArgs e) { }
|
||||||
|
|
||||||
|
public void Initialise(EbayListingService listingService, EbayCategoryService categoryService,
|
||||||
|
AiAssistantService aiService, EbayAuthService auth,
|
||||||
|
SavedListingsService savedService, string defaultPostcode)
|
||||||
|
{
|
||||||
|
_listingService = listingService;
|
||||||
|
_categoryService = categoryService;
|
||||||
|
_aiService = aiService;
|
||||||
|
_auth = auth;
|
||||||
|
_savedService = savedService;
|
||||||
|
_defaultPostcode = defaultPostcode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- State machine ----
|
||||||
|
|
||||||
|
private enum ListingState { DropZone, ReviewEdit, Success }
|
||||||
|
|
||||||
|
private void SetState(ListingState state)
|
||||||
|
{
|
||||||
|
StateA.Visibility = state == ListingState.DropZone ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
StateB.Visibility = state == ListingState.ReviewEdit ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
StateC.Visibility = state == ListingState.Success ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- State A: Drop zone ----
|
||||||
|
|
||||||
|
private void DropZone_DragOver(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
|
||||||
|
? DragDropEffects.Copy : DragDropEffects.None;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropZone_DragEnter(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropZone_DragLeave(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
DropBorderRect.Stroke = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray6");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropZone_Drop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
DropZone_DragLeave(sender, e);
|
||||||
|
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
||||||
|
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
||||||
|
AddPhotos(files.Where(IsImageFile).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DropZone_Click(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
var dlg = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Select photos of the item",
|
||||||
|
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*",
|
||||||
|
Multiselect = true
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() == true)
|
||||||
|
AddPhotos(dlg.FileNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddPhotos(string[] paths)
|
||||||
|
{
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
if (_photoPaths.Count >= MaxPhotos) break;
|
||||||
|
if (_photoPaths.Contains(path)) continue;
|
||||||
|
if (!IsImageFile(path)) continue;
|
||||||
|
_photoPaths.Add(path);
|
||||||
|
}
|
||||||
|
UpdateThumbStrip();
|
||||||
|
UpdateAnalyseButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateThumbStrip()
|
||||||
|
{
|
||||||
|
ThumbStrip.Children.Clear();
|
||||||
|
ThumbScroller.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
foreach (var path in _photoPaths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bmp = new BitmapImage();
|
||||||
|
bmp.BeginInit();
|
||||||
|
bmp.UriSource = new Uri(path, UriKind.Absolute);
|
||||||
|
bmp.DecodePixelWidth = 120;
|
||||||
|
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
|
bmp.EndInit();
|
||||||
|
bmp.Freeze();
|
||||||
|
|
||||||
|
var img = new Image
|
||||||
|
{
|
||||||
|
Source = bmp, Width = 96, Height = 96,
|
||||||
|
Stretch = System.Windows.Media.Stretch.UniformToFill,
|
||||||
|
Margin = new Thickness(4)
|
||||||
|
};
|
||||||
|
img.Clip = new System.Windows.Media.RectangleGeometry(
|
||||||
|
new Rect(0, 0, 96, 96), 6, 6);
|
||||||
|
ThumbStrip.Children.Add(img);
|
||||||
|
}
|
||||||
|
catch { /* skip bad files */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoCountLabel.Text = $"{_photoPaths.Count} / {MaxPhotos} photos";
|
||||||
|
PhotoCountLabel.Visibility = _photoPaths.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateAnalyseButton()
|
||||||
|
{
|
||||||
|
AnalyseBtn.IsEnabled = _photoPaths.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Analyse_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_aiService == null || _photoPaths.Count == 0) return;
|
||||||
|
SetAnalysing(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _aiService.AnalyseItemFromPhotosAsync(_photoPaths);
|
||||||
|
_lastAnalysis = result;
|
||||||
|
await PopulateStateBAsync(result);
|
||||||
|
SetState(ListingState.ReviewEdit);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SetAnalysing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetAnalysing(bool busy)
|
||||||
|
{
|
||||||
|
AnalyseBtn.IsEnabled = !busy;
|
||||||
|
AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI";
|
||||||
|
LoadingPanel.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
DropZoneBorder.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
if (busy)
|
||||||
|
{
|
||||||
|
_loadingStep = 0;
|
||||||
|
LoadingStepText.Text = LoadingSteps[0];
|
||||||
|
_loadingTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_loadingTimer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- State B: Populate from analysis ----
|
||||||
|
|
||||||
|
private async Task PopulateStateBAsync(PhotoAnalysisResult result)
|
||||||
|
{
|
||||||
|
_draft = new ListingDraft { Postcode = _defaultPostcode };
|
||||||
|
_draft.PhotoPaths = new List<string>(_photoPaths);
|
||||||
|
RebuildBPhotoThumbnails();
|
||||||
|
|
||||||
|
BTitleBox.Text = result.Title;
|
||||||
|
BDescBox.Text = result.Description;
|
||||||
|
BPriceBox.Value = (double)Math.Round(result.PriceSuggested, 2);
|
||||||
|
BPostcodeBox.Text = _defaultPostcode;
|
||||||
|
BConditionBox.SelectedIndex = 3; // Used
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.CategoryKeyword))
|
||||||
|
await AutoFillCategoryAsync(result.CategoryKeyword);
|
||||||
|
|
||||||
|
if (result.PriceMin > 0 && result.PriceMax > 0)
|
||||||
|
{
|
||||||
|
BPriceHint.Text = $"AI estimate: \u00A3{result.PriceMin:F2} – \u00A3{result.PriceMax:F2}";
|
||||||
|
BPriceHint.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Title ----
|
||||||
|
|
||||||
|
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_draft.Title = BTitleBox.Text;
|
||||||
|
var len = BTitleBox.Text.Length;
|
||||||
|
BTitleCount.Text = $"{len} / 80";
|
||||||
|
var over = len > 75;
|
||||||
|
var trackBorder = BTitleBar.Parent as Border;
|
||||||
|
double trackWidth = trackBorder?.ActualWidth ?? 0;
|
||||||
|
if (trackWidth > 0) BTitleBar.Width = trackWidth * (len / 80.0);
|
||||||
|
BTitleBar.Background = over
|
||||||
|
? System.Windows.Media.Brushes.OrangeRed
|
||||||
|
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
|
||||||
|
BTitleCount.Foreground = over
|
||||||
|
? System.Windows.Media.Brushes.OrangeRed
|
||||||
|
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void AiTitle_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_aiService == null) return;
|
||||||
|
SetTitleBusy(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var title = await _aiService.GenerateTitleAsync(BTitleBox.Text, GetSelectedCondition().ToString());
|
||||||
|
BTitleBox.Text = title.Trim().TrimEnd('.').Trim('"');
|
||||||
|
if (string.IsNullOrWhiteSpace(_draft.CategoryId))
|
||||||
|
await AutoFillCategoryAsync(BTitleBox.Text);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ShowError("AI Title", ex.Message); }
|
||||||
|
finally { SetTitleBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetTitleBusy(bool busy)
|
||||||
|
{
|
||||||
|
AiTitleBtn.IsEnabled = !busy;
|
||||||
|
TitleSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
TitleAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Description ----
|
||||||
|
|
||||||
|
private void DescBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_draft.Description = BDescBox.Text;
|
||||||
|
var len = BDescBox.Text.Length;
|
||||||
|
const int softCap = 2000;
|
||||||
|
BDescCount.Text = $"{len} / {softCap}";
|
||||||
|
var over = len > softCap;
|
||||||
|
var trackBorder = BDescBar.Parent as Border;
|
||||||
|
double trackWidth = trackBorder?.ActualWidth ?? 0;
|
||||||
|
if (trackWidth > 0) BDescBar.Width = Math.Min(trackWidth, trackWidth * (len / (double)softCap));
|
||||||
|
BDescBar.Background = over
|
||||||
|
? System.Windows.Media.Brushes.OrangeRed
|
||||||
|
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xF5, 0x9E, 0x0B));
|
||||||
|
BDescCount.Foreground = over
|
||||||
|
? System.Windows.Media.Brushes.OrangeRed
|
||||||
|
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void AiDesc_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_aiService == null) return;
|
||||||
|
SetDescBusy(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var desc = await _aiService.WriteDescriptionAsync(
|
||||||
|
BTitleBox.Text, GetSelectedCondition().ToString(), BDescBox.Text);
|
||||||
|
BDescBox.Text = desc;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ShowError("AI Description", ex.Message); }
|
||||||
|
finally { SetDescBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetDescBusy(bool busy)
|
||||||
|
{
|
||||||
|
AiDescBtn.IsEnabled = !busy;
|
||||||
|
DescSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
DescAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Category ----
|
||||||
|
|
||||||
|
private void CategoryBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressCategoryLookup) return;
|
||||||
|
_categoryCts?.Cancel();
|
||||||
|
_categoryCts?.Dispose();
|
||||||
|
_categoryCts = new System.Threading.CancellationTokenSource();
|
||||||
|
var cts = _categoryCts;
|
||||||
|
if (BCategoryBox.Text.Length < 3) { BCategoryList.Visibility = Visibility.Collapsed; return; }
|
||||||
|
_ = SearchCategoryAsync(BCategoryBox.Text, cts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SearchCategoryAsync(string text, System.Threading.CancellationTokenSource cts)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(350, cts.Token);
|
||||||
|
if (cts.IsCancellationRequested) return;
|
||||||
|
var suggestions = await _categoryService!.GetCategorySuggestionsAsync(text);
|
||||||
|
if (cts.IsCancellationRequested) return;
|
||||||
|
BCategoryList.ItemsSource = suggestions.Select(s => s.CategoryName).ToList();
|
||||||
|
BCategoryList.Tag = suggestions;
|
||||||
|
BCategoryList.Visibility = suggestions.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CategoryBox_KeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Escape) { BCategoryList.Visibility = Visibility.Collapsed; e.Handled = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CategoryList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (BCategoryList.SelectedIndex < 0) return;
|
||||||
|
var suggestions = BCategoryList.Tag as List<CategorySuggestion>;
|
||||||
|
if (suggestions == null || BCategoryList.SelectedIndex >= suggestions.Count) return;
|
||||||
|
var cat = suggestions[BCategoryList.SelectedIndex];
|
||||||
|
_suppressCategoryLookup = true;
|
||||||
|
_draft.CategoryId = cat.CategoryId;
|
||||||
|
_draft.CategoryName = cat.CategoryName;
|
||||||
|
BCategoryBox.Text = cat.CategoryName;
|
||||||
|
BCategoryIdLabel.Text = $"ID: {cat.CategoryId}";
|
||||||
|
BCategoryList.Visibility = Visibility.Collapsed;
|
||||||
|
_suppressCategoryLookup = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AutoFillCategoryAsync(string keyword)
|
||||||
|
{
|
||||||
|
if (_categoryService == null || string.IsNullOrWhiteSpace(keyword)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var suggestions = await _categoryService.GetCategorySuggestionsAsync(keyword);
|
||||||
|
if (suggestions.Count == 0) return;
|
||||||
|
var top = suggestions[0];
|
||||||
|
_suppressCategoryLookup = true;
|
||||||
|
_draft.CategoryId = top.CategoryId;
|
||||||
|
_draft.CategoryName = top.CategoryName;
|
||||||
|
BCategoryBox.Text = top.CategoryName;
|
||||||
|
BCategoryIdLabel.Text = $"ID: {top.CategoryId}";
|
||||||
|
_suppressCategoryLookup = false;
|
||||||
|
BCategoryList.ItemsSource = suggestions.Select(s => s.CategoryName).ToList();
|
||||||
|
BCategoryList.Tag = suggestions;
|
||||||
|
BCategoryList.Visibility = suggestions.Count > 1 ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Condition ----
|
||||||
|
|
||||||
|
private void ConditionBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_draft.Condition = GetSelectedCondition();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemCondition GetSelectedCondition()
|
||||||
|
{
|
||||||
|
var tag = (BConditionBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Used";
|
||||||
|
return tag switch
|
||||||
|
{
|
||||||
|
"New" => ItemCondition.New,
|
||||||
|
"OpenBox" => ItemCondition.OpenBox,
|
||||||
|
"Refurbished" => ItemCondition.Refurbished,
|
||||||
|
"ForParts" => ItemCondition.ForPartsOrNotWorking,
|
||||||
|
_ => ItemCondition.Used
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Price ----
|
||||||
|
|
||||||
|
private async void AiPrice_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_aiService == null) return;
|
||||||
|
SetPriceBusy(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _aiService.SuggestPriceAsync(BTitleBox.Text, GetSelectedCondition().ToString());
|
||||||
|
var lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var priceLine = lines.FirstOrDefault(l => l.StartsWith("PRICE:", StringComparison.OrdinalIgnoreCase));
|
||||||
|
_suggestedPriceValue = priceLine?.Replace("PRICE:", "", StringComparison.OrdinalIgnoreCase).Trim() ?? "";
|
||||||
|
BPriceHint.Text = lines.FirstOrDefault() ?? result;
|
||||||
|
BPriceHint.Visibility = Visibility.Visible;
|
||||||
|
if (decimal.TryParse(_suggestedPriceValue, out var price))
|
||||||
|
BPriceBox.Value = (double)price;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ShowError("AI Price", ex.Message); }
|
||||||
|
finally { SetPriceBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PriceBox_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double?> e)
|
||||||
|
=> UpdateFeeEstimate();
|
||||||
|
|
||||||
|
private void PostageBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
=> UpdateFeeEstimate();
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, decimal> PostageEstimates = new()
|
||||||
|
{
|
||||||
|
["RoyalMailFirstClass"] = 3.70m,
|
||||||
|
["RoyalMailSecondClass"] = 2.85m,
|
||||||
|
["RoyalMailTracked24"] = 4.35m,
|
||||||
|
["RoyalMailTracked48"] = 3.60m,
|
||||||
|
["CollectionOnly"] = 0m,
|
||||||
|
["FreePostage"] = 0m,
|
||||||
|
};
|
||||||
|
|
||||||
|
private void UpdateFeeEstimate()
|
||||||
|
{
|
||||||
|
if (BFeeLabel == null) return;
|
||||||
|
var price = (decimal)(BPriceBox?.Value ?? 0);
|
||||||
|
if (price <= 0) { BFeeLabel.Visibility = Visibility.Collapsed; return; }
|
||||||
|
|
||||||
|
var postageTag = (BPostageBox?.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "";
|
||||||
|
PostageEstimates.TryGetValue(postageTag, out var postageEst);
|
||||||
|
|
||||||
|
const decimal fvfRate = 0.128m;
|
||||||
|
const decimal minFee = 0.30m;
|
||||||
|
var fee = Math.Max(Math.Round((price + postageEst) * fvfRate, 2), minFee);
|
||||||
|
|
||||||
|
var postageNote = postageEst > 0 ? $" + est. \u00A3{postageEst:F2} postage" : "";
|
||||||
|
BFeeLabel.Text = $"Est. eBay fee: \u00A3{fee:F2} (12.8% of \u00A3{price:F2}{postageNote})";
|
||||||
|
BFeeLabel.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
private void SetPriceBusy(bool busy)
|
||||||
|
{
|
||||||
|
AiPriceBtn.IsEnabled = !busy;
|
||||||
|
PriceSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
PriceAiIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Photos (State B) ----
|
||||||
|
|
||||||
|
private void RebuildBPhotoThumbnails()
|
||||||
|
{
|
||||||
|
BPhotosPanel.Children.Clear();
|
||||||
|
for (int i = 0; i < _draft.PhotoPaths.Count; i++)
|
||||||
|
AddBPhotoThumbnail(_draft.PhotoPaths[i], i);
|
||||||
|
BPhotoCount.Text = $"{_draft.PhotoPaths.Count} / {MaxPhotos}";
|
||||||
|
BPhotoCount.Foreground = _draft.PhotoPaths.Count >= MaxPhotos
|
||||||
|
? System.Windows.Media.Brushes.OrangeRed
|
||||||
|
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddBPhotoThumbnail(string path, int index)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bmp = new BitmapImage();
|
||||||
|
bmp.BeginInit();
|
||||||
|
bmp.UriSource = new Uri(path, UriKind.Absolute);
|
||||||
|
bmp.DecodePixelWidth = 160;
|
||||||
|
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||||
|
bmp.EndInit();
|
||||||
|
bmp.Freeze();
|
||||||
|
|
||||||
|
var img = new Image
|
||||||
|
{
|
||||||
|
Width = 100, Height = 100,
|
||||||
|
Stretch = System.Windows.Media.Stretch.UniformToFill,
|
||||||
|
Source = bmp, ToolTip = System.IO.Path.GetFileName(path)
|
||||||
|
};
|
||||||
|
img.Clip = new System.Windows.Media.RectangleGeometry(new Rect(0, 0, 100, 100), 6, 6);
|
||||||
|
|
||||||
|
var removeBtn = new Button
|
||||||
|
{
|
||||||
|
Width = 18, Height = 18, Content = "\u2715",
|
||||||
|
FontSize = 11, FontWeight = FontWeights.Bold,
|
||||||
|
Cursor = Cursors.Hand, ToolTip = "Remove",
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(0, 2, 2, 0), Padding = new Thickness(0),
|
||||||
|
Background = new System.Windows.Media.SolidColorBrush(
|
||||||
|
System.Windows.Media.Color.FromArgb(200, 30, 30, 30)),
|
||||||
|
Foreground = System.Windows.Media.Brushes.White,
|
||||||
|
BorderThickness = new Thickness(0), Opacity = 0
|
||||||
|
};
|
||||||
|
removeBtn.Click += (s, ev) =>
|
||||||
|
{
|
||||||
|
ev.Handled = true;
|
||||||
|
_draft.PhotoPaths.Remove(path);
|
||||||
|
RebuildBPhotoThumbnails();
|
||||||
|
};
|
||||||
|
|
||||||
|
Border? coverBadge = null;
|
||||||
|
if (index == 0)
|
||||||
|
{
|
||||||
|
coverBadge = new Border
|
||||||
|
{
|
||||||
|
CornerRadius = new CornerRadius(3),
|
||||||
|
Background = new System.Windows.Media.SolidColorBrush(
|
||||||
|
System.Windows.Media.Color.FromArgb(210, 60, 90, 200)),
|
||||||
|
Padding = new Thickness(3, 1, 3, 1),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Left,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(2, 2, 0, 0),
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "Cover", FontSize = 8, FontWeight = FontWeights.SemiBold,
|
||||||
|
Foreground = System.Windows.Media.Brushes.White
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = new Grid
|
||||||
|
{
|
||||||
|
Width = 100, Height = 100, Margin = new Thickness(4),
|
||||||
|
Cursor = Cursors.SizeAll, AllowDrop = true, Tag = path
|
||||||
|
};
|
||||||
|
container.Children.Add(img);
|
||||||
|
if (coverBadge != null) container.Children.Add(coverBadge);
|
||||||
|
container.Children.Add(removeBtn);
|
||||||
|
|
||||||
|
container.MouseEnter += (s, ev) => removeBtn.Opacity = 1;
|
||||||
|
container.MouseLeave += (s, ev) => removeBtn.Opacity = 0;
|
||||||
|
|
||||||
|
Point dragStart = default;
|
||||||
|
bool isDragging = false;
|
||||||
|
container.MouseLeftButtonDown += (s, ev) => dragStart = ev.GetPosition(null);
|
||||||
|
container.MouseMove += (s, ev) =>
|
||||||
|
{
|
||||||
|
if (ev.LeftButton != MouseButtonState.Pressed || isDragging) return;
|
||||||
|
var pos = ev.GetPosition(null);
|
||||||
|
if (Math.Abs(pos.X - dragStart.X) > SystemParameters.MinimumHorizontalDragDistance ||
|
||||||
|
Math.Abs(pos.Y - dragStart.Y) > SystemParameters.MinimumVerticalDragDistance)
|
||||||
|
{
|
||||||
|
isDragging = true;
|
||||||
|
DragDrop.DoDragDrop(container, path, DragDropEffects.Move);
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
container.DragOver += (s, ev) =>
|
||||||
|
{
|
||||||
|
if (ev.Data.GetDataPresent(typeof(string)) &&
|
||||||
|
(string)ev.Data.GetData(typeof(string)) != path)
|
||||||
|
{ ev.Effects = DragDropEffects.Move; container.Opacity = 0.45; }
|
||||||
|
else ev.Effects = DragDropEffects.None;
|
||||||
|
ev.Handled = true;
|
||||||
|
};
|
||||||
|
container.DragLeave += (s, ev) => container.Opacity = 1.0;
|
||||||
|
container.Drop += (s, ev) =>
|
||||||
|
{
|
||||||
|
container.Opacity = 1.0;
|
||||||
|
if (!ev.Data.GetDataPresent(typeof(string))) return;
|
||||||
|
var src = (string)ev.Data.GetData(typeof(string));
|
||||||
|
var tgt = (string)container.Tag;
|
||||||
|
if (src == tgt) return;
|
||||||
|
var si = _draft.PhotoPaths.IndexOf(src);
|
||||||
|
var ti = _draft.PhotoPaths.IndexOf(tgt);
|
||||||
|
if (si < 0 || ti < 0) return;
|
||||||
|
_draft.PhotoPaths.RemoveAt(si);
|
||||||
|
_draft.PhotoPaths.Insert(ti, src);
|
||||||
|
RebuildBPhotoThumbnails();
|
||||||
|
ev.Handled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
BPhotosPanel.Children.Add(container);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddMorePhotos_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var dlg = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Add more photos",
|
||||||
|
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*",
|
||||||
|
Multiselect = true
|
||||||
|
};
|
||||||
|
if (dlg.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
foreach (var path in dlg.FileNames)
|
||||||
|
{
|
||||||
|
if (_draft.PhotoPaths.Count >= MaxPhotos) break;
|
||||||
|
if (!_draft.PhotoPaths.Contains(path)) _draft.PhotoPaths.Add(path);
|
||||||
|
}
|
||||||
|
RebuildBPhotoThumbnails();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Footer actions ----
|
||||||
|
|
||||||
|
private void StartOver_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var isDirty = !string.IsNullOrWhiteSpace(BTitleBox.Text) ||
|
||||||
|
!string.IsNullOrWhiteSpace(BDescBox.Text);
|
||||||
|
if (isDirty)
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show("Start over? Any edits will be lost.",
|
||||||
|
"Start Over", MessageBoxButton.OKCancel, MessageBoxImage.Question);
|
||||||
|
if (result != MessageBoxResult.OK) return;
|
||||||
|
}
|
||||||
|
ResetToStateA();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetToStateA()
|
||||||
|
{
|
||||||
|
_photoPaths.Clear();
|
||||||
|
_draft = new ListingDraft { Postcode = _defaultPostcode };
|
||||||
|
_lastAnalysis = null;
|
||||||
|
UpdateThumbStrip();
|
||||||
|
UpdateAnalyseButton();
|
||||||
|
if (BPhotosPanel != null) BPhotosPanel.Children.Clear();
|
||||||
|
if (BTitleBox != null) BTitleBox.Text = "";
|
||||||
|
if (BDescBox != null) BDescBox.Text = "";
|
||||||
|
if (BCategoryBox != null) { BCategoryBox.Text = ""; BCategoryList.Visibility = Visibility.Collapsed; }
|
||||||
|
if (BCategoryIdLabel != null) BCategoryIdLabel.Text = "(no category selected)";
|
||||||
|
if (BPriceBox != null) BPriceBox.Value = 0;
|
||||||
|
if (BPriceHint != null) BPriceHint.Visibility = Visibility.Collapsed;
|
||||||
|
if (BConditionBox != null) BConditionBox.SelectedIndex = 3;
|
||||||
|
if (BFormatBox != null) BFormatBox.SelectedIndex = 0;
|
||||||
|
if (BPostcodeBox != null) BPostcodeBox.Text = _defaultPostcode;
|
||||||
|
SetState(ListingState.DropZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveDraft_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_savedService == null) return;
|
||||||
|
if (!ValidateDraft()) return;
|
||||||
|
CollectDraftFromFields();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_savedService.Save(
|
||||||
|
_draft.Title, _draft.Description, _draft.Price,
|
||||||
|
_draft.CategoryName, "",
|
||||||
|
_draft.PhotoPaths,
|
||||||
|
_draft.CategoryId, _draft.Condition, _draft.Format,
|
||||||
|
BPostcodeBox.Text);
|
||||||
|
GetWindow()?.RefreshSavedListings();
|
||||||
|
GetWindow()?.SetStatus($"Draft saved: {_draft.Title}");
|
||||||
|
ResetToStateA();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { ShowError("Save Failed", ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Post_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_listingService == null) return;
|
||||||
|
if (!ValidateDraft()) return;
|
||||||
|
CollectDraftFromFields();
|
||||||
|
SetPostBusy(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = await _listingService.PostListingAsync(_draft);
|
||||||
|
_draft.EbayListingUrl = url;
|
||||||
|
|
||||||
|
// Persist a record of the posting
|
||||||
|
if (_savedService != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_savedService.Save(
|
||||||
|
_draft.Title, _draft.Description, _draft.Price,
|
||||||
|
_draft.CategoryName, $"Posted: {url}",
|
||||||
|
_draft.PhotoPaths,
|
||||||
|
_draft.CategoryId, _draft.Condition, _draft.Format,
|
||||||
|
_draft.Postcode);
|
||||||
|
GetWindow()?.RefreshSavedListings();
|
||||||
|
}
|
||||||
|
catch { /* non-critical — posting succeeded, history save is best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
BSuccessUrl.Text = url;
|
||||||
|
SetState(ListingState.Success);
|
||||||
|
GetWindow()?.SetStatus($"Listed: {_draft.Title}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log full stack trace to help diagnose crashes
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logPath = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"EbayListingTool", "crash_log.txt");
|
||||||
|
var msg = $"{DateTime.Now:HH:mm:ss} [Post_Click] {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n";
|
||||||
|
if (ex.InnerException != null)
|
||||||
|
msg += $" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}\n";
|
||||||
|
System.IO.File.AppendAllText(logPath, msg + "\n");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
ShowError("Post Failed", ex.Message);
|
||||||
|
}
|
||||||
|
finally { SetPostBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CollectDraftFromFields()
|
||||||
|
{
|
||||||
|
_draft.Title = BTitleBox.Text.Trim();
|
||||||
|
_draft.Description = BDescBox.Text.Trim();
|
||||||
|
_draft.Price = (decimal)(BPriceBox.Value ?? 0);
|
||||||
|
_draft.Condition = GetSelectedCondition();
|
||||||
|
_draft.Format = BFormatBox.SelectedIndex == 0 ? ListingFormat.FixedPrice : ListingFormat.Auction;
|
||||||
|
_draft.Postcode = BPostcodeBox.Text;
|
||||||
|
_draft.Quantity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ValidateDraft()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(BTitleBox?.Text))
|
||||||
|
{ ShowError("Validation", "Please enter a title."); return false; }
|
||||||
|
if (BTitleBox.Text.Length > 80)
|
||||||
|
{ ShowError("Validation", "Title must be 80 characters or fewer."); return false; }
|
||||||
|
if (string.IsNullOrEmpty(_draft.CategoryId))
|
||||||
|
{ ShowError("Validation", "Please select a category."); return false; }
|
||||||
|
if ((BPriceBox?.Value ?? 0) <= 0)
|
||||||
|
{ ShowError("Validation", "Please enter a price greater than zero."); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPostBusy(bool busy)
|
||||||
|
{
|
||||||
|
PostBtn.IsEnabled = !busy;
|
||||||
|
PostSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
PostIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
IsEnabled = !busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowError(string title, string msg)
|
||||||
|
=> MessageBox.Show(msg, title, MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
|
||||||
|
// ---- State C handlers ----
|
||||||
|
|
||||||
|
private void SuccessUrl_Click(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
var url = BSuccessUrl.Text;
|
||||||
|
if (!string.IsNullOrEmpty(url))
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
|
||||||
|
{ UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyUrl_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> System.Windows.Clipboard.SetText(BSuccessUrl.Text);
|
||||||
|
|
||||||
|
private void ListAnother_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> ResetToStateA();
|
||||||
|
|
||||||
|
private static bool IsImageFile(string path)
|
||||||
|
{
|
||||||
|
var ext = System.IO.Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
|
||||||
|
}
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
<UserControl x:Class="EbayListingTool.Views.PhotoAnalysisView"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
|
||||||
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks">
|
|
||||||
|
|
||||||
<UserControl.Resources>
|
|
||||||
|
|
||||||
<!-- ================================================================
|
|
||||||
Styles
|
|
||||||
================================================================ -->
|
|
||||||
<Style x:Key="SectionCard" TargetType="Border">
|
|
||||||
<Setter Property="BorderThickness" Value="1"/>
|
|
||||||
<Setter Property="CornerRadius" Value="6"/>
|
|
||||||
<Setter Property="Padding" Value="14,12"/>
|
|
||||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
|
|
||||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
|
||||||
</Style>
|
|
||||||
<Style x:Key="SectionHeading" TargetType="TextBlock">
|
|
||||||
<Setter Property="FontSize" Value="10"/>
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
|
||||||
</Style>
|
|
||||||
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="FontSize" Value="12"/>
|
|
||||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray2}"/>
|
|
||||||
</Style>
|
|
||||||
<Style x:Key="ResultValue" TargetType="TextBlock">
|
|
||||||
<Setter Property="FontSize" Value="13"/>
|
|
||||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray1}"/>
|
|
||||||
</Style>
|
|
||||||
<Style x:Key="AiButton" TargetType="Button"
|
|
||||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
|
||||||
<Setter Property="Foreground" Value="White"/>
|
|
||||||
<Setter Property="BorderThickness" Value="0"/>
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="Background">
|
|
||||||
<Setter.Value>
|
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
|
||||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
|
||||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
<Style.Triggers>
|
|
||||||
<Trigger Property="IsMouseOver" Value="True">
|
|
||||||
<Setter Property="Background">
|
|
||||||
<Setter.Value>
|
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
|
||||||
<GradientStop Color="#8B5CF6" Offset="0"/>
|
|
||||||
<GradientStop Color="#6366F1" Offset="1"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Trigger>
|
|
||||||
<Trigger Property="IsEnabled" Value="False">
|
|
||||||
<Setter Property="Opacity" Value="0.45"/>
|
|
||||||
</Trigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Small icon-only clipboard button -->
|
|
||||||
<Style x:Key="CopyButton" TargetType="Button"
|
|
||||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
|
||||||
<Setter Property="Width" Value="28"/>
|
|
||||||
<Setter Property="Height" Value="28"/>
|
|
||||||
<Setter Property="Padding" Value="4"/>
|
|
||||||
<Setter Property="BorderThickness" Value="1"/>
|
|
||||||
<Setter Property="ToolTip" Value="Copy to clipboard"/>
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ================================================================
|
|
||||||
Drop zone dashed border animation
|
|
||||||
================================================================ -->
|
|
||||||
<Style x:Key="DashedDropBorder" TargetType="Border">
|
|
||||||
<Setter Property="BorderThickness" Value="2"/>
|
|
||||||
<Setter Property="CornerRadius" Value="10"/>
|
|
||||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
|
||||||
<Setter Property="AllowDrop" Value="True"/>
|
|
||||||
<Setter Property="MinHeight" Value="320"/>
|
|
||||||
<Setter Property="Cursor" Value="Hand"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- ================================================================
|
|
||||||
Results reveal animation
|
|
||||||
================================================================ -->
|
|
||||||
<Storyboard x:Key="ResultsReveal">
|
|
||||||
<DoubleAnimation Storyboard.TargetName="ResultsPanel"
|
|
||||||
Storyboard.TargetProperty="Opacity"
|
|
||||||
From="0" To="1" Duration="0:0:0.25"/>
|
|
||||||
<DoubleAnimation Storyboard.TargetName="ResultsTranslate"
|
|
||||||
Storyboard.TargetProperty="Y"
|
|
||||||
From="20" To="0" Duration="0:0:0.25">
|
|
||||||
<DoubleAnimation.EasingFunction>
|
|
||||||
<CubicEase EasingMode="EaseOut"/>
|
|
||||||
</DoubleAnimation.EasingFunction>
|
|
||||||
</DoubleAnimation>
|
|
||||||
</Storyboard>
|
|
||||||
|
|
||||||
<!-- Camera icon pulse animation — both axes target the same ScaleTransform -->
|
|
||||||
<Storyboard x:Key="CameraPulse" RepeatBehavior="Forever">
|
|
||||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="CameraScale"
|
|
||||||
Storyboard.TargetProperty="ScaleX">
|
|
||||||
<EasingDoubleKeyFrame KeyTime="0:0:0.0" Value="1.0"/>
|
|
||||||
<EasingDoubleKeyFrame KeyTime="0:0:1.0" Value="1.08">
|
|
||||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
|
||||||
</EasingDoubleKeyFrame>
|
|
||||||
<EasingDoubleKeyFrame KeyTime="0:0:2.0" Value="1.0">
|
|
||||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
|
||||||
</EasingDoubleKeyFrame>
|
|
||||||
</DoubleAnimationUsingKeyFrames>
|
|
||||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="CameraScale"
|
|
||||||
Storyboard.TargetProperty="ScaleY">
|
|
||||||
<EasingDoubleKeyFrame KeyTime="0:0:0.0" Value="1.0"/>
|
|
||||||
<EasingDoubleKeyFrame KeyTime="0:0:1.0" Value="1.08">
|
|
||||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
|
||||||
</EasingDoubleKeyFrame>
|
|
||||||
<EasingDoubleKeyFrame KeyTime="0:0:2.0" Value="1.0">
|
|
||||||
<EasingDoubleKeyFrame.EasingFunction><SineEase EasingMode="EaseInOut"/></EasingDoubleKeyFrame.EasingFunction>
|
|
||||||
</EasingDoubleKeyFrame>
|
|
||||||
</DoubleAnimationUsingKeyFrames>
|
|
||||||
</Storyboard>
|
|
||||||
|
|
||||||
</UserControl.Resources>
|
|
||||||
|
|
||||||
<!-- Start animations when control loads -->
|
|
||||||
<UserControl.Triggers>
|
|
||||||
<EventTrigger RoutedEvent="Loaded">
|
|
||||||
<BeginStoryboard Storyboard="{StaticResource CameraPulse}"/>
|
|
||||||
</EventTrigger>
|
|
||||||
</UserControl.Triggers>
|
|
||||||
|
|
||||||
<Grid Margin="16,12">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="340"/>
|
|
||||||
<ColumnDefinition Width="12"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- ================================================================
|
|
||||||
LEFT: Photo drop zone + analyse button
|
|
||||||
================================================================ -->
|
|
||||||
<Grid Grid.Column="0">
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- Drop zone with dashed border drawn via Rectangle overlay -->
|
|
||||||
<Grid Grid.Row="0">
|
|
||||||
<!-- Dashed border rectangle -->
|
|
||||||
<Rectangle x:Name="DropBorderRect"
|
|
||||||
RadiusX="10" RadiusY="10"
|
|
||||||
StrokeDashArray="6,4"
|
|
||||||
StrokeThickness="2"
|
|
||||||
Stroke="{DynamicResource MahApps.Brushes.Gray6}"
|
|
||||||
Fill="Transparent"
|
|
||||||
IsHitTestVisible="False"/>
|
|
||||||
|
|
||||||
<Border x:Name="DropZone"
|
|
||||||
CornerRadius="10"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray10}"
|
|
||||||
AllowDrop="True"
|
|
||||||
Drop="DropZone_Drop"
|
|
||||||
DragOver="DropZone_DragOver"
|
|
||||||
DragEnter="DropZone_DragEnter"
|
|
||||||
DragLeave="DropZone_DragLeave"
|
|
||||||
MinHeight="320"
|
|
||||||
Cursor="Hand"
|
|
||||||
MouseLeftButtonUp="DropZone_Click">
|
|
||||||
|
|
||||||
<!-- Wrapper grid so Border has only one child; children overlap via shared cell -->
|
|
||||||
<Grid>
|
|
||||||
<!-- Empty state hint -->
|
|
||||||
<Grid x:Name="DropHint" VerticalAlignment="Center" HorizontalAlignment="Center">
|
|
||||||
<StackPanel HorizontalAlignment="Center">
|
|
||||||
<iconPacks:PackIconMaterial Kind="CameraPlus"
|
|
||||||
x:Name="CameraIcon"
|
|
||||||
Width="64" Height="64"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
Margin="0,0,0,16"
|
|
||||||
RenderTransformOrigin="0.5,0.5">
|
|
||||||
<iconPacks:PackIconMaterial.RenderTransform>
|
|
||||||
<ScaleTransform x:Name="CameraScale" ScaleX="1" ScaleY="1"/>
|
|
||||||
</iconPacks:PackIconMaterial.RenderTransform>
|
|
||||||
</iconPacks:PackIconMaterial>
|
|
||||||
<TextBlock Text="Drop a photo here" FontSize="16" FontWeight="SemiBold"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
|
|
||||||
<TextBlock Text="or click to browse" FontSize="12" Margin="0,4,0,0"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
|
||||||
<TextBlock Text="JPG · PNG · GIF · WEBP" FontSize="11" Margin="0,14,0,0"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Loaded photo with rounded clip and drop shadow -->
|
|
||||||
<Grid x:Name="PhotoPreviewContainer" Visibility="Collapsed">
|
|
||||||
<Grid.Effect>
|
|
||||||
<DropShadowEffect BlurRadius="12" ShadowDepth="3" Opacity="0.25" Color="Black"/>
|
|
||||||
</Grid.Effect>
|
|
||||||
<Image x:Name="PhotoPreview"
|
|
||||||
Stretch="Uniform"
|
|
||||||
Margin="4"
|
|
||||||
RenderOptions.BitmapScalingMode="HighQuality">
|
|
||||||
<Image.Clip>
|
|
||||||
<RectangleGeometry x:Name="PhotoClip" RadiusX="8" RadiusY="8"/>
|
|
||||||
</Image.Clip>
|
|
||||||
</Image>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Clear photo button (top-right overlay) -->
|
|
||||||
<Button x:Name="ClearPhotoBtn"
|
|
||||||
Visibility="Collapsed"
|
|
||||||
Click="ClearPhoto_Click"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
Margin="0,8,8,0"
|
|
||||||
Width="24" Height="24"
|
|
||||||
Padding="3"
|
|
||||||
ToolTip="Remove photo"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Background="#CC222222"
|
|
||||||
BorderThickness="0">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Close" Width="12" Height="12" Foreground="White"/>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Photo filename label -->
|
|
||||||
<TextBlock x:Name="PhotoFilename" Grid.Row="1"
|
|
||||||
Text="" FontSize="11" Margin="0,6,0,0"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
TextTrimming="CharacterEllipsis"/>
|
|
||||||
|
|
||||||
<!-- Thumbnail strip (hidden until 2+ photos loaded) -->
|
|
||||||
<ScrollViewer Grid.Row="2"
|
|
||||||
x:Name="ThumbStripScroller"
|
|
||||||
HorizontalScrollBarVisibility="Auto"
|
|
||||||
VerticalScrollBarVisibility="Disabled"
|
|
||||||
Visibility="Collapsed"
|
|
||||||
Margin="0,8,0,0">
|
|
||||||
<WrapPanel x:Name="PhotoThumbStrip"
|
|
||||||
Orientation="Horizontal"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<!-- Analyse button -->
|
|
||||||
<Button Grid.Row="3" x:Name="AnalyseBtn"
|
|
||||||
Click="Analyse_Click"
|
|
||||||
Style="{StaticResource AiButton}"
|
|
||||||
Height="42" FontSize="15" Margin="0,10,0,0"
|
|
||||||
IsEnabled="False">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<mah:ProgressRing x:Name="AnalyseSpinner"
|
|
||||||
Width="18" Height="18" Margin="0,0,8,0"
|
|
||||||
Foreground="White" Visibility="Collapsed"/>
|
|
||||||
<iconPacks:PackIconMaterial x:Name="AnalyseIcon"
|
|
||||||
Kind="Magnify" Width="18" Height="18"
|
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock x:Name="AnalyseBtnText"
|
|
||||||
Text="Identify & Price with AI"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- ================================================================
|
|
||||||
RIGHT: Results panel
|
|
||||||
================================================================ -->
|
|
||||||
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
|
|
||||||
<StackPanel>
|
|
||||||
|
|
||||||
<!-- Idle state -->
|
|
||||||
<Border x:Name="IdlePanel" Style="{StaticResource SectionCard}"
|
|
||||||
MinHeight="400">
|
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
|
||||||
<iconPacks:PackIconMaterial Kind="TagOutline"
|
|
||||||
Width="52" Height="52"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
|
||||||
Margin="0,0,0,16"/>
|
|
||||||
<TextBlock Text="Drop a photo and click Identify"
|
|
||||||
FontSize="15" FontWeight="SemiBold"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
|
||||||
<TextBlock Text="Claude will identify the item, write a listing description and suggest a realistic eBay UK selling price."
|
|
||||||
FontSize="12" Margin="0,8,0,0"
|
|
||||||
HorizontalAlignment="Center" TextAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
|
||||||
TextWrapping="Wrap" MaxWidth="320"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Loading state (shown during analysis) -->
|
|
||||||
<Border x:Name="LoadingPanel" Style="{StaticResource SectionCard}"
|
|
||||||
MinHeight="400" Visibility="Collapsed">
|
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
|
||||||
<mah:ProgressRing Width="48" Height="48"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Margin="0,0,0,20"/>
|
|
||||||
<TextBlock x:Name="LoadingStepText"
|
|
||||||
Text="Examining the photo…"
|
|
||||||
FontSize="15" FontWeight="SemiBold"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray3}"/>
|
|
||||||
<TextBlock Text="This usually takes 10–20 seconds"
|
|
||||||
FontSize="11" Margin="0,8,0,0"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Results (hidden until analysis complete) -->
|
|
||||||
<StackPanel x:Name="ResultsPanel" Visibility="Collapsed" Opacity="0">
|
|
||||||
<StackPanel.RenderTransform>
|
|
||||||
<TranslateTransform x:Name="ResultsTranslate" Y="20"/>
|
|
||||||
</StackPanel.RenderTransform>
|
|
||||||
|
|
||||||
<!-- Identified item -->
|
|
||||||
<Border Style="{StaticResource SectionCard}">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
|
||||||
<iconPacks:PackIconMaterial Kind="CartOutline" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="ITEM IDENTIFIED" Style="{StaticResource SectionHeading}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Item name — large bold -->
|
|
||||||
<TextBlock x:Name="ItemNameText"
|
|
||||||
FontSize="20" FontWeight="Bold"
|
|
||||||
TextWrapping="Wrap" Margin="0,0,0,8"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
|
|
||||||
|
|
||||||
<!-- Brand/model pill badge -->
|
|
||||||
<Border x:Name="BrandPill"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
CornerRadius="12"
|
|
||||||
Padding="10,3"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
Margin="0,0,0,10"
|
|
||||||
Visibility="Collapsed">
|
|
||||||
<TextBlock x:Name="BrandModelText"
|
|
||||||
FontSize="11" FontWeight="SemiBold"
|
|
||||||
Foreground="White"/>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Condition notes — green tinted box with eye icon -->
|
|
||||||
<Border Background="#1A4CAF50" BorderBrush="#4CAF50"
|
|
||||||
BorderThickness="1" CornerRadius="5" Padding="10,8">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Eye" Width="13" Height="13"
|
|
||||||
Margin="0,0,8,0" VerticalAlignment="Top"
|
|
||||||
Foreground="#4CAF50"/>
|
|
||||||
<TextBlock x:Name="ConditionText"
|
|
||||||
FontSize="12" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"
|
|
||||||
MaxWidth="340"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Confidence badge (High / Medium / Low) -->
|
|
||||||
<Border x:Name="ConfidenceBadge"
|
|
||||||
CornerRadius="10" Padding="8,3"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
Margin="0,10,0,0"
|
|
||||||
Visibility="Collapsed">
|
|
||||||
<TextBlock x:Name="ConfidenceText"
|
|
||||||
FontSize="10" FontWeight="SemiBold"
|
|
||||||
Foreground="White"/>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Confidence notes -->
|
|
||||||
<TextBlock x:Name="ConfidenceNotesText"
|
|
||||||
FontSize="11" FontStyle="Italic"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Margin="0,6,0,0"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
Visibility="Collapsed"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Price -->
|
|
||||||
<Border Style="{StaticResource SectionCard}">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
|
||||||
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="SUGGESTED PRICE" Style="{StaticResource SectionHeading}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Prominent price badge -->
|
|
||||||
<Border HorizontalAlignment="Left"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
CornerRadius="10"
|
|
||||||
Padding="18,8"
|
|
||||||
Margin="0,0,0,10">
|
|
||||||
<TextBlock x:Name="PriceSuggestedText"
|
|
||||||
FontSize="38" FontWeight="Bold"
|
|
||||||
Foreground="White"/>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Min · Suggested · Max visual bar -->
|
|
||||||
<Grid x:Name="PriceRangeBar" Margin="0,0,0,12" Visibility="Collapsed">
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
<!-- Connecting line -->
|
|
||||||
<Border Grid.Row="0" Height="2" Margin="12,0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray7}"
|
|
||||||
CornerRadius="1"/>
|
|
||||||
<!-- Three dots + labels -->
|
|
||||||
<Grid Grid.Row="0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<Ellipse Grid.Column="0" Width="10" Height="10"
|
|
||||||
Fill="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Ellipse Grid.Column="2" Width="14" Height="14"
|
|
||||||
Fill="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Ellipse Grid.Column="4" Width="10" Height="10"
|
|
||||||
Fill="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
|
||||||
<!-- Labels row -->
|
|
||||||
<Grid Grid.Row="1" Margin="0,4,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<StackPanel Grid.Column="0" HorizontalAlignment="Center">
|
|
||||||
<TextBlock Text="MIN" FontSize="9" FontWeight="SemiBold"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
<TextBlock x:Name="PriceMinText" FontSize="11"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="2" HorizontalAlignment="Center">
|
|
||||||
<TextBlock Text="SUGGESTED" FontSize="9" FontWeight="SemiBold"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="4" HorizontalAlignment="Center">
|
|
||||||
<TextBlock Text="MAX" FontSize="9" FontWeight="SemiBold"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
<TextBlock x:Name="PriceMaxText" FontSize="11"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Editable price override -->
|
|
||||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="Override price:" FontSize="11"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
|
||||||
<mah:NumericUpDown x:Name="PriceOverride"
|
|
||||||
Width="110" Height="32"
|
|
||||||
Minimum="0" Maximum="99999"
|
|
||||||
StringFormat="F2" Interval="0.5"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Live eBay price status -->
|
|
||||||
<StackPanel x:Name="LivePriceRow" Orientation="Horizontal"
|
|
||||||
Margin="0,6,0,0" Visibility="Collapsed">
|
|
||||||
<mah:ProgressRing x:Name="LivePriceSpinner"
|
|
||||||
Width="11" Height="11" Margin="0,0,6,0"/>
|
|
||||||
<TextBlock x:Name="LivePriceStatus"
|
|
||||||
FontSize="10"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
<!-- Price reasoning -->
|
|
||||||
<TextBlock x:Name="PriceReasoningText"
|
|
||||||
FontSize="11" FontStyle="Italic"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Margin="0,8,0,0"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
Visibility="Collapsed"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<Border Style="{StaticResource SectionCard}">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8"
|
|
||||||
HorizontalAlignment="Stretch">
|
|
||||||
<iconPacks:PackIconMaterial Kind="TagOutline" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="LISTING TITLE" Style="{StaticResource SectionHeading}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Button x:Name="CopyTitleBtn"
|
|
||||||
Style="{StaticResource CopyButton}"
|
|
||||||
Click="CopyTitle_Click"
|
|
||||||
Margin="8,0,0,0"
|
|
||||||
ToolTip="Copy title to clipboard">
|
|
||||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"/>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBox x:Name="TitleBox"
|
|
||||||
MaxLength="80"
|
|
||||||
mah:TextBoxHelper.Watermark="Listing title (max 80 chars)"
|
|
||||||
TextChanged="TitleBox_TextChanged"/>
|
|
||||||
<TextBlock x:Name="TitleCount" Text="0 / 80" FontSize="10"
|
|
||||||
HorizontalAlignment="Right" Margin="0,3,0,0"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<Border Style="{StaticResource SectionCard}">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
|
||||||
<iconPacks:PackIconMaterial Kind="TextBox" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="DESCRIPTION" Style="{StaticResource SectionHeading}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Button x:Name="CopyDescBtn"
|
|
||||||
Style="{StaticResource CopyButton}"
|
|
||||||
Click="CopyDescription_Click"
|
|
||||||
Margin="8,0,0,0"
|
|
||||||
ToolTip="Copy description to clipboard">
|
|
||||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"/>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBox x:Name="DescriptionBox"
|
|
||||||
Height="180" AcceptsReturn="True"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
VerticalScrollBarVisibility="Auto"
|
|
||||||
Style="{DynamicResource MahApps.Styles.TextBox}"
|
|
||||||
FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Corrections for AI refinement -->
|
|
||||||
<Border BorderThickness="1" CornerRadius="8" Padding="14,10"
|
|
||||||
Margin="0,0,0,10"
|
|
||||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray9}">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Pencil" Width="12" Height="12"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="CORRECTIONS" FontSize="10" FontWeight="SemiBold"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBox x:Name="CorrectionsBox"
|
|
||||||
AcceptsReturn="False"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Height="52"
|
|
||||||
VerticalScrollBarVisibility="Auto"
|
|
||||||
Style="{DynamicResource MahApps.Styles.TextBox}"
|
|
||||||
FontSize="12"
|
|
||||||
mah:TextBoxHelper.Watermark="e.g. earrings are white gold with diamonds, not silver and zirconium"
|
|
||||||
Margin="0,0,0,8"/>
|
|
||||||
<Button x:Name="RefineBtn"
|
|
||||||
Click="Refine_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="32" Padding="12,0" HorizontalAlignment="Left">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial x:Name="RefineIcon" Kind="AutoFix" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<mah:ProgressRing x:Name="RefineSpinner" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" Visibility="Collapsed"/>
|
|
||||||
<TextBlock x:Name="RefineBtnText" Text="Refine with AI"
|
|
||||||
VerticalAlignment="Center" FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Actions + toast overlay -->
|
|
||||||
<Grid Margin="0,4,0,16" ClipToBounds="False">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<Button x:Name="UseDetailsBtn"
|
|
||||||
Content="Use for New Listing →"
|
|
||||||
Click="UseDetails_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
|
||||||
Height="36" Padding="16,0" FontSize="13" FontWeight="SemiBold"/>
|
|
||||||
<Button x:Name="SaveListingBtn"
|
|
||||||
Click="SaveListing_Click"
|
|
||||||
Margin="8,0,0,0"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="36" Padding="14,0">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="14" Height="14"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
x:Name="SaveIcon"/>
|
|
||||||
<iconPacks:PackIconMaterial Kind="BookmarkCheck" Width="14" Height="14"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
x:Name="SavedIcon" Visibility="Collapsed"
|
|
||||||
Foreground="LimeGreen"/>
|
|
||||||
<TextBlock x:Name="SaveBtnText" Text="Save Listing"
|
|
||||||
VerticalAlignment="Center" FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button x:Name="ReAnalyseBtn"
|
|
||||||
Click="ReAnalyse_Click"
|
|
||||||
Margin="8,0,0,0"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="36" Padding="12,0"
|
|
||||||
ToolTip="Re-run AI analysis with the same photo(s)">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Refresh" Width="14" Height="14"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Re-analyse" VerticalAlignment="Center" FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Content="Analyse Another"
|
|
||||||
Click="AnalyseAnother_Click"
|
|
||||||
Margin="8,0,0,0"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="36" Padding="12,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Save confirmation toast -->
|
|
||||||
<Border x:Name="SaveToast"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
|
||||||
CornerRadius="6"
|
|
||||||
Padding="14,8"
|
|
||||||
Margin="0,0,0,-48"
|
|
||||||
Visibility="Collapsed"
|
|
||||||
IsHitTestVisible="False">
|
|
||||||
<Border.RenderTransform>
|
|
||||||
<TranslateTransform x:Name="ToastTranslate" Y="40"/>
|
|
||||||
</Border.RenderTransform>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Check" Width="14" Height="14"
|
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
|
||||||
Foreground="White"/>
|
|
||||||
<TextBlock Text="Saved to Saved Listings"
|
|
||||||
FontSize="12" FontWeight="SemiBold"
|
|
||||||
Foreground="White" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,710 +0,0 @@
|
|||||||
using System.Windows;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Media;
|
|
||||||
using System.Windows.Media.Animation;
|
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using System.Windows.Threading;
|
|
||||||
using EbayListingTool.Models;
|
|
||||||
using EbayListingTool.Services;
|
|
||||||
using Microsoft.Win32;
|
|
||||||
|
|
||||||
namespace EbayListingTool.Views;
|
|
||||||
|
|
||||||
public partial class PhotoAnalysisView : UserControl
|
|
||||||
{
|
|
||||||
private AiAssistantService? _aiService;
|
|
||||||
private SavedListingsService? _savedService;
|
|
||||||
private EbayPriceResearchService? _priceService;
|
|
||||||
private List<string> _currentImagePaths = new();
|
|
||||||
private PhotoAnalysisResult? _lastResult;
|
|
||||||
private int _activePhotoIndex = 0;
|
|
||||||
private DispatcherTimer? _saveBtnTimer; // M1: field so we can stop it on Unloaded
|
|
||||||
private DispatcherTimer? _holdTimer; // Q2: field so we can stop it on Unloaded
|
|
||||||
|
|
||||||
private const int MaxPhotos = 4;
|
|
||||||
|
|
||||||
// Loading step cycling
|
|
||||||
private readonly DispatcherTimer _loadingTimer;
|
|
||||||
private int _loadingStep;
|
|
||||||
private static readonly string[] LoadingSteps =
|
|
||||||
[
|
|
||||||
"Examining the photo\u2026",
|
|
||||||
"Identifying the item\u2026",
|
|
||||||
"Researching eBay prices\u2026",
|
|
||||||
"Writing description\u2026"
|
|
||||||
];
|
|
||||||
|
|
||||||
// Event raised when user clicks "Use for New Listing" — Q1: passes all loaded photos
|
|
||||||
public event Action<PhotoAnalysisResult, IReadOnlyList<string>, decimal>? UseDetailsRequested;
|
|
||||||
|
|
||||||
public PhotoAnalysisView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
_loadingTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
|
||||||
_loadingTimer.Tick += LoadingTimer_Tick;
|
|
||||||
|
|
||||||
// M1 / Q2: stop timers when control is unloaded to avoid memory leaks
|
|
||||||
Unloaded += (_, _) => { _saveBtnTimer?.Stop(); _holdTimer?.Stop(); };
|
|
||||||
|
|
||||||
// Keep photo clip geometry in sync with container size
|
|
||||||
PhotoPreviewContainer.SizeChanged += PhotoPreviewContainer_SizeChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Initialise(AiAssistantService aiService, SavedListingsService savedService,
|
|
||||||
EbayPriceResearchService priceService)
|
|
||||||
{
|
|
||||||
_aiService = aiService;
|
|
||||||
_savedService = savedService;
|
|
||||||
_priceService = priceService;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Photo clip geometry sync ----
|
|
||||||
|
|
||||||
private void PhotoPreviewContainer_SizeChanged(object sender, SizeChangedEventArgs e)
|
|
||||||
{
|
|
||||||
PhotoClip.Rect = new System.Windows.Rect(0, 0, e.NewSize.Width, e.NewSize.Height);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Drop zone ----
|
|
||||||
|
|
||||||
private void DropZone_DragOver(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
|
|
||||||
? DragDropEffects.Copy : DragDropEffects.None;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DropZone_DragEnter(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
// Solid accent border on drag-enter
|
|
||||||
DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Accent");
|
|
||||||
DropBorderRect.StrokeDashArray = null;
|
|
||||||
DropBorderRect.StrokeThickness = 2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DropZone_DragLeave(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
DropBorderRect.Stroke = (Brush)FindResource("MahApps.Brushes.Gray6");
|
|
||||||
DropBorderRect.StrokeDashArray = new DoubleCollection([6, 4]);
|
|
||||||
DropBorderRect.StrokeThickness = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DropZone_Drop(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
DropZone_DragLeave(sender, e);
|
|
||||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
|
||||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
|
||||||
foreach (var file in files.Where(IsImageFile))
|
|
||||||
{
|
|
||||||
LoadPhoto(file);
|
|
||||||
if (_currentImagePaths.Count >= MaxPhotos) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DropZone_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
|
||||||
{
|
|
||||||
var dlg = new OpenFileDialog
|
|
||||||
{
|
|
||||||
Title = "Select photo(s) of the item",
|
|
||||||
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp|All files|*.*",
|
|
||||||
Multiselect = true
|
|
||||||
};
|
|
||||||
if (dlg.ShowDialog() != true) return;
|
|
||||||
foreach (var file in dlg.FileNames)
|
|
||||||
{
|
|
||||||
LoadPhoto(file);
|
|
||||||
if (_currentImagePaths.Count >= MaxPhotos) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearPhoto_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_currentImagePaths.Clear();
|
|
||||||
_activePhotoIndex = 0;
|
|
||||||
PhotoPreview.Source = null;
|
|
||||||
PhotoPreviewContainer.Visibility = Visibility.Collapsed;
|
|
||||||
ClearPhotoBtn.Visibility = Visibility.Collapsed;
|
|
||||||
DropHint.Visibility = Visibility.Visible;
|
|
||||||
PhotoFilename.Text = "";
|
|
||||||
AnalyseBtn.IsEnabled = false;
|
|
||||||
|
|
||||||
UpdateThumbStrip();
|
|
||||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
|
||||||
ResultsPanel.Opacity = 0;
|
|
||||||
IdlePanel.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds <paramref name="path"/> to the photo list (up to MaxPhotos).
|
|
||||||
/// The preview always shows the most recently added image.
|
|
||||||
/// </summary>
|
|
||||||
private void LoadPhoto(string path)
|
|
||||||
{
|
|
||||||
if (_currentImagePaths.Contains(path)) return;
|
|
||||||
if (_currentImagePaths.Count >= MaxPhotos)
|
|
||||||
{
|
|
||||||
MessageBox.Show($"You can add up to {MaxPhotos} photos. Remove one before adding more.",
|
|
||||||
"Photo limit reached", MessageBoxButton.OK, MessageBoxImage.Information);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_currentImagePaths.Add(path);
|
|
||||||
|
|
||||||
var bmp = new BitmapImage();
|
|
||||||
bmp.BeginInit();
|
|
||||||
bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
|
|
||||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
|
||||||
bmp.DecodePixelWidth = 600;
|
|
||||||
bmp.EndInit();
|
|
||||||
bmp.Freeze(); // M2: cross-thread safe, reduces GC pressure
|
|
||||||
|
|
||||||
PhotoPreview.Source = bmp;
|
|
||||||
PhotoPreviewContainer.Visibility = Visibility.Visible;
|
|
||||||
ClearPhotoBtn.Visibility = Visibility.Visible;
|
|
||||||
DropHint.Visibility = Visibility.Collapsed;
|
|
||||||
|
|
||||||
_activePhotoIndex = _currentImagePaths.Count - 1;
|
|
||||||
|
|
||||||
UpdatePhotoFilenameLabel();
|
|
||||||
UpdateThumbStrip();
|
|
||||||
AnalyseBtn.IsEnabled = true;
|
|
||||||
|
|
||||||
// Collapse results so user re-analyses after adding photos
|
|
||||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
|
||||||
ResultsPanel.Opacity = 0;
|
|
||||||
IdlePanel.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_currentImagePaths.Remove(path);
|
|
||||||
MessageBox.Show($"Could not load image: {ex.Message}", "Error",
|
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdatePhotoFilenameLabel()
|
|
||||||
{
|
|
||||||
PhotoFilename.Text = _currentImagePaths.Count switch
|
|
||||||
{
|
|
||||||
0 => "",
|
|
||||||
1 => Path.GetFileName(_currentImagePaths[0]),
|
|
||||||
_ => $"{_currentImagePaths.Count} photos loaded"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Analyse ----
|
|
||||||
|
|
||||||
private async void Analyse_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_aiService == null || _currentImagePaths.Count == 0) return;
|
|
||||||
|
|
||||||
SetAnalysing(true);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _aiService.AnalyseItemFromPhotosAsync(_currentImagePaths);
|
|
||||||
_lastResult = result;
|
|
||||||
ShowResults(result);
|
|
||||||
// Fire live price lookup in background — updates price display when ready
|
|
||||||
_ = UpdateLivePricesAsync(result.Title);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MessageBox.Show($"Analysis failed:\n\n{ex.Message}", "AI Error",
|
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
SetAnalysing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-analyse simply repeats the same call — idempotent by design
|
|
||||||
private void ReAnalyse_Click(object sender, RoutedEventArgs e)
|
|
||||||
=> Analyse_Click(sender, e);
|
|
||||||
|
|
||||||
private async void Refine_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_aiService == null || _lastResult == null) return;
|
|
||||||
|
|
||||||
var corrections = CorrectionsBox.Text.Trim();
|
|
||||||
if (string.IsNullOrEmpty(corrections))
|
|
||||||
{
|
|
||||||
CorrectionsBox.Focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var title = TitleBox.Text;
|
|
||||||
var description = DescriptionBox.Text;
|
|
||||||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
|
||||||
|
|
||||||
SetRefining(true);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (newTitle, newDesc, newPrice, newReasoning) =
|
|
||||||
await _aiService.RefineWithCorrectionsAsync(title, description, price, corrections);
|
|
||||||
|
|
||||||
TitleBox.Text = newTitle;
|
|
||||||
DescriptionBox.Text = newDesc;
|
|
||||||
PriceOverride.Value = (double)Math.Round(newPrice, 2); // Issue 6
|
|
||||||
PriceSuggestedText.Text = newPrice > 0 ? $"£{newPrice:F2}" : "—";
|
|
||||||
|
|
||||||
_lastResult.Title = newTitle;
|
|
||||||
_lastResult.Description = newDesc;
|
|
||||||
_lastResult.PriceSuggested = newPrice;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(newReasoning))
|
|
||||||
{
|
|
||||||
PriceReasoningText.Text = newReasoning;
|
|
||||||
PriceReasoningText.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the corrections box now they're applied
|
|
||||||
CorrectionsBox.Text = "";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MessageBox.Show($"Refinement failed:\n\n{ex.Message}", "AI Error",
|
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
SetRefining(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetRefining(bool busy)
|
|
||||||
{
|
|
||||||
RefineBtn.IsEnabled = !busy;
|
|
||||||
RefineIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
RefineSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
RefineBtnText.Text = busy ? "Refining…" : "Refine with AI";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowResults(PhotoAnalysisResult r)
|
|
||||||
{
|
|
||||||
IdlePanel.Visibility = Visibility.Collapsed;
|
|
||||||
LoadingPanel.Visibility = Visibility.Collapsed;
|
|
||||||
ResultsPanel.Visibility = Visibility.Visible;
|
|
||||||
|
|
||||||
// Item identification
|
|
||||||
ItemNameText.Text = r.ItemName;
|
|
||||||
|
|
||||||
var brandModel = string.Join(" \u00b7 ",
|
|
||||||
new[] { r.Brand, r.Model }.Where(s => !string.IsNullOrWhiteSpace(s)));
|
|
||||||
if (!string.IsNullOrWhiteSpace(brandModel))
|
|
||||||
{
|
|
||||||
BrandModelText.Text = brandModel;
|
|
||||||
BrandPill.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
BrandPill.Visibility = Visibility.Collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
ConditionText.Text = r.ConditionNotes;
|
|
||||||
|
|
||||||
// Confidence badge
|
|
||||||
ConfidenceBadge.Visibility = Visibility.Collapsed;
|
|
||||||
if (!string.IsNullOrWhiteSpace(r.IdentificationConfidence))
|
|
||||||
{
|
|
||||||
ConfidenceText.Text = r.IdentificationConfidence;
|
|
||||||
ConfidenceBadge.Background = r.IdentificationConfidence.ToLower() switch
|
|
||||||
{
|
|
||||||
"high" => new SolidColorBrush(Color.FromRgb(34, 139, 34)),
|
|
||||||
"medium" => new SolidColorBrush(Color.FromRgb(210, 140, 0)),
|
|
||||||
_ => new SolidColorBrush(Color.FromRgb(192, 0, 0))
|
|
||||||
};
|
|
||||||
ConfidenceBadge.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
ConfidenceNotesText.Text = r.ConfidenceNotes;
|
|
||||||
ConfidenceNotesText.Visibility = string.IsNullOrWhiteSpace(r.ConfidenceNotes)
|
|
||||||
? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
|
|
||||||
// Price badge
|
|
||||||
PriceSuggestedText.Text = r.PriceSuggested > 0 ? $"\u00a3{r.PriceSuggested:F2}" : "\u2014";
|
|
||||||
|
|
||||||
// Price range bar
|
|
||||||
if (r.PriceMin > 0 && r.PriceMax > 0)
|
|
||||||
{
|
|
||||||
PriceMinText.Text = $"\u00a3{r.PriceMin:F2}";
|
|
||||||
PriceMaxText.Text = $"\u00a3{r.PriceMax:F2}";
|
|
||||||
PriceRangeBar.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PriceRangeBar.Visibility = Visibility.Collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
PriceOverride.Value = (double)Math.Round(r.PriceSuggested, 2); // Issue 6
|
|
||||||
|
|
||||||
// Price reasoning
|
|
||||||
PriceReasoningText.Text = r.PriceReasoning;
|
|
||||||
PriceReasoningText.Visibility = string.IsNullOrWhiteSpace(r.PriceReasoning)
|
|
||||||
? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
|
|
||||||
// Editable fields
|
|
||||||
TitleBox.Text = r.Title;
|
|
||||||
DescriptionBox.Text = r.Description;
|
|
||||||
|
|
||||||
// Reset live price row until lookup completes
|
|
||||||
LivePriceRow.Visibility = Visibility.Collapsed;
|
|
||||||
|
|
||||||
// Animate results in
|
|
||||||
var sb = (Storyboard)FindResource("ResultsReveal");
|
|
||||||
sb.Begin(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateLivePricesAsync(string query)
|
|
||||||
{
|
|
||||||
if (_priceService == null) return;
|
|
||||||
|
|
||||||
// Issue 7: guard against off-thread callers (fire-and-forget may lose sync context)
|
|
||||||
if (!Dispatcher.CheckAccess())
|
|
||||||
{
|
|
||||||
await Dispatcher.InvokeAsync(() => UpdateLivePricesAsync(query)).Task.Unwrap();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Issue 1: spinner-show inside try so a disposed control doesn't crash the caller
|
|
||||||
LivePriceRow.Visibility = Visibility.Visible;
|
|
||||||
LivePriceSpinner.Visibility = Visibility.Visible;
|
|
||||||
LivePriceStatus.Text = "Checking live eBay UK prices…";
|
|
||||||
|
|
||||||
var live = await _priceService.GetLivePricesAsync(query);
|
|
||||||
|
|
||||||
if (live.Count == 0)
|
|
||||||
{
|
|
||||||
LivePriceStatus.Text = "No matching live listings found.";
|
|
||||||
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update range bar with real data
|
|
||||||
PriceMinText.Text = $"£{live.Min:F2}";
|
|
||||||
PriceMaxText.Text = $"£{live.Max:F2}";
|
|
||||||
PriceRangeBar.Visibility = Visibility.Visible;
|
|
||||||
|
|
||||||
// Update suggested price to 40th percentile (competitive but not cheapest)
|
|
||||||
var suggested = live.Suggested;
|
|
||||||
PriceSuggestedText.Text = $"£{suggested:F2}";
|
|
||||||
PriceOverride.Value = (double)Math.Round(suggested, 2); // Issue 6: avoid decimal→double drift
|
|
||||||
if (_lastResult != null) _lastResult.PriceSuggested = suggested;
|
|
||||||
|
|
||||||
// Update status label
|
|
||||||
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
|
||||||
LivePriceStatus.Text =
|
|
||||||
$"Based on {live.Count} live eBay UK listing{(live.Count == 1 ? "" : "s")} " +
|
|
||||||
$"(range £{live.Min:F2} – £{live.Max:F2})";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
LivePriceSpinner.Visibility = Visibility.Collapsed;
|
|
||||||
LivePriceStatus.Text = $"Live price lookup unavailable: {ex.Message}";
|
|
||||||
}
|
|
||||||
catch { /* control may be unloaded by the time catch runs */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
|
||||||
{
|
|
||||||
var len = TitleBox.Text.Length;
|
|
||||||
TitleCount.Text = $"{len} / 80";
|
|
||||||
TitleCount.Foreground = len > 75
|
|
||||||
? Brushes.OrangeRed
|
|
||||||
: (Brush)FindResource("MahApps.Brushes.Gray5");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UseDetails_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_lastResult == null) return;
|
|
||||||
|
|
||||||
// Copy any edits back into result before passing on
|
|
||||||
_lastResult.Title = TitleBox.Text;
|
|
||||||
_lastResult.Description = DescriptionBox.Text;
|
|
||||||
|
|
||||||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
|
||||||
UseDetailsRequested?.Invoke(_lastResult, _currentImagePaths, price); // Q1: pass all photos
|
|
||||||
|
|
||||||
// Switch to New Listing tab
|
|
||||||
if (Window.GetWindow(this) is MainWindow mw)
|
|
||||||
mw.SwitchToNewListingTab();
|
|
||||||
|
|
||||||
GetWindow()?.SetStatus($"Details loaded for: {_lastResult.Title}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveListing_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_lastResult == null || _savedService == null) return;
|
|
||||||
|
|
||||||
// Use edited title/description if the user changed them
|
|
||||||
var title = TitleBox.Text.Trim();
|
|
||||||
var description = DescriptionBox.Text.Trim();
|
|
||||||
var price = (decimal)(PriceOverride.Value ?? (double)_lastResult.PriceSuggested);
|
|
||||||
|
|
||||||
_savedService.Save(title, description, price,
|
|
||||||
_lastResult.CategoryKeyword, _lastResult.ConditionNotes,
|
|
||||||
_currentImagePaths);
|
|
||||||
|
|
||||||
// Brief visual confirmation on the button — M1: use field timer, stop previous if re-saved quickly
|
|
||||||
_saveBtnTimer?.Stop();
|
|
||||||
SaveIcon.Visibility = Visibility.Collapsed;
|
|
||||||
SavedIcon.Visibility = Visibility.Visible;
|
|
||||||
SaveBtnText.Text = "Saved!";
|
|
||||||
|
|
||||||
_saveBtnTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
|
|
||||||
_saveBtnTimer.Tick += (s, _) =>
|
|
||||||
{
|
|
||||||
_saveBtnTimer?.Stop();
|
|
||||||
SaveIcon.Visibility = Visibility.Visible;
|
|
||||||
SavedIcon.Visibility = Visibility.Collapsed;
|
|
||||||
SaveBtnText.Text = "Save Listing";
|
|
||||||
};
|
|
||||||
_saveBtnTimer.Start();
|
|
||||||
|
|
||||||
ShowSaveToast();
|
|
||||||
|
|
||||||
// Notify main window to refresh the gallery
|
|
||||||
(Window.GetWindow(this) as MainWindow)?.RefreshSavedListings();
|
|
||||||
GetWindow()?.SetStatus($"Saved: {title}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AnalyseAnother_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_currentImagePaths.Clear();
|
|
||||||
_lastResult = null;
|
|
||||||
_activePhotoIndex = 0;
|
|
||||||
PhotoPreview.Source = null;
|
|
||||||
PhotoPreviewContainer.Visibility = Visibility.Collapsed;
|
|
||||||
ClearPhotoBtn.Visibility = Visibility.Collapsed;
|
|
||||||
DropHint.Visibility = Visibility.Visible;
|
|
||||||
PhotoFilename.Text = "";
|
|
||||||
AnalyseBtn.IsEnabled = false;
|
|
||||||
UpdateThumbStrip();
|
|
||||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
|
||||||
ResultsPanel.Opacity = 0;
|
|
||||||
IdlePanel.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Thumb strip ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rebuilds the thumbnail strip from <see cref="_currentImagePaths"/>.
|
|
||||||
/// Shows/hides the strip based on whether 2+ photos are loaded.
|
|
||||||
/// </summary>
|
|
||||||
private void UpdateThumbStrip()
|
|
||||||
{
|
|
||||||
PhotoThumbStrip.Children.Clear();
|
|
||||||
|
|
||||||
if (_currentImagePaths.Count < 2)
|
|
||||||
{
|
|
||||||
ThumbStripScroller.Visibility = Visibility.Collapsed;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ThumbStripScroller.Visibility = Visibility.Visible;
|
|
||||||
|
|
||||||
var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
|
|
||||||
var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
|
|
||||||
|
|
||||||
for (int i = 0; i < _currentImagePaths.Count; i++)
|
|
||||||
{
|
|
||||||
var index = i; // capture for closure
|
|
||||||
var path = _currentImagePaths[i];
|
|
||||||
|
|
||||||
BitmapImage? thumb = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
thumb = new BitmapImage();
|
|
||||||
thumb.BeginInit();
|
|
||||||
thumb.UriSource = new Uri(path, UriKind.Absolute); // W1
|
|
||||||
thumb.CacheOption = BitmapCacheOption.OnLoad;
|
|
||||||
thumb.DecodePixelWidth = 80;
|
|
||||||
thumb.EndInit();
|
|
||||||
thumb.Freeze(); // M2
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Skip thumbnails that fail to load
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isActive = (index == _activePhotoIndex);
|
|
||||||
|
|
||||||
var img = new Image
|
|
||||||
{
|
|
||||||
Source = thumb,
|
|
||||||
Width = 60,
|
|
||||||
Height = 60,
|
|
||||||
Stretch = Stretch.UniformToFill
|
|
||||||
};
|
|
||||||
RenderOptions.SetBitmapScalingMode(img, BitmapScalingMode.HighQuality);
|
|
||||||
// Clip image to rounded rect
|
|
||||||
img.Clip = new System.Windows.Media.RectangleGeometry(
|
|
||||||
new System.Windows.Rect(0, 0, 60, 60), 4, 4);
|
|
||||||
|
|
||||||
var border = new Border
|
|
||||||
{
|
|
||||||
Width = 64,
|
|
||||||
Height = 64,
|
|
||||||
Margin = new Thickness(3),
|
|
||||||
CornerRadius = new CornerRadius(5),
|
|
||||||
BorderThickness = new Thickness(isActive ? 2.5 : 1.5),
|
|
||||||
BorderBrush = isActive ? accentBrush : neutralBrush,
|
|
||||||
Background = System.Windows.Media.Brushes.Transparent,
|
|
||||||
Cursor = System.Windows.Input.Cursors.Hand,
|
|
||||||
Child = img
|
|
||||||
};
|
|
||||||
|
|
||||||
border.MouseLeftButtonUp += (_, _) =>
|
|
||||||
{
|
|
||||||
_activePhotoIndex = index;
|
|
||||||
|
|
||||||
// Load that photo into main preview
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bmp = new BitmapImage();
|
|
||||||
bmp.BeginInit();
|
|
||||||
bmp.UriSource = new Uri(_currentImagePaths[index], UriKind.Absolute); // W1
|
|
||||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
|
||||||
bmp.DecodePixelWidth = 600;
|
|
||||||
bmp.EndInit();
|
|
||||||
bmp.Freeze(); // M2
|
|
||||||
PhotoPreview.Source = bmp;
|
|
||||||
}
|
|
||||||
catch { /* silently ignore */ }
|
|
||||||
|
|
||||||
// Q3: full rebuild avoids index-desync when thumbnails skipped on load error
|
|
||||||
UpdateThumbStrip();
|
|
||||||
};
|
|
||||||
|
|
||||||
PhotoThumbStrip.Children.Add(border);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates only the border highlights on the existing thumb strip children
|
|
||||||
/// after the active index changes, avoiding a full thumbnail reload.
|
|
||||||
/// </summary>
|
|
||||||
private void UpdateThumbStripHighlight()
|
|
||||||
{
|
|
||||||
var accentBrush = (Brush)FindResource("MahApps.Brushes.Accent");
|
|
||||||
var neutralBrush = (Brush)FindResource("MahApps.Brushes.Gray7");
|
|
||||||
|
|
||||||
int childIndex = 0;
|
|
||||||
for (int i = 0; i < _currentImagePaths.Count; i++)
|
|
||||||
{
|
|
||||||
if (childIndex >= PhotoThumbStrip.Children.Count) break;
|
|
||||||
if (PhotoThumbStrip.Children[childIndex] is Border b)
|
|
||||||
{
|
|
||||||
bool isActive = (i == _activePhotoIndex);
|
|
||||||
b.BorderBrush = isActive ? accentBrush : neutralBrush;
|
|
||||||
b.BorderThickness = new Thickness(isActive ? 2.5 : 1.5);
|
|
||||||
}
|
|
||||||
childIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Save toast ----
|
|
||||||
|
|
||||||
private void ShowSaveToast()
|
|
||||||
{
|
|
||||||
// Issue 8: always restart — stop any in-progress hold timer and cancel the running
|
|
||||||
// animation so the flag can never get permanently stuck and rapid saves feel responsive.
|
|
||||||
_holdTimer?.Stop();
|
|
||||||
_holdTimer = null;
|
|
||||||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, null); // cancel current animation
|
|
||||||
|
|
||||||
SaveToast.Visibility = Visibility.Visible;
|
|
||||||
|
|
||||||
// Slide in: Y from +40 to 0
|
|
||||||
var slideIn = new DoubleAnimation(40, 0, new Duration(TimeSpan.FromMilliseconds(220)))
|
|
||||||
{
|
|
||||||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
|
||||||
};
|
|
||||||
|
|
||||||
// After 2.5 s total: slide out Y from 0 to +40, then hide
|
|
||||||
slideIn.Completed += (_, _) =>
|
|
||||||
{
|
|
||||||
_holdTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(2500 - 220) }; // Q2: field
|
|
||||||
_holdTimer.Tick += (s2, _) =>
|
|
||||||
{
|
|
||||||
_holdTimer.Stop();
|
|
||||||
var slideOut = new DoubleAnimation(0, 40, new Duration(TimeSpan.FromMilliseconds(180)))
|
|
||||||
{
|
|
||||||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
|
|
||||||
};
|
|
||||||
slideOut.Completed += (_, _) =>
|
|
||||||
{
|
|
||||||
SaveToast.Visibility = Visibility.Collapsed;
|
|
||||||
};
|
|
||||||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideOut);
|
|
||||||
};
|
|
||||||
_holdTimer.Start();
|
|
||||||
};
|
|
||||||
|
|
||||||
ToastTranslate.BeginAnimation(TranslateTransform.YProperty, slideIn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Copy buttons ----
|
|
||||||
|
|
||||||
private void CopyTitle_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(TitleBox.Text))
|
|
||||||
Clipboard.SetText(TitleBox.Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CopyDescription_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(DescriptionBox.Text))
|
|
||||||
Clipboard.SetText(DescriptionBox.Text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Loading step cycling ----
|
|
||||||
|
|
||||||
private void LoadingTimer_Tick(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
_loadingStep = (_loadingStep + 1) % LoadingSteps.Length;
|
|
||||||
LoadingStepText.Text = LoadingSteps[_loadingStep];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
|
||||||
|
|
||||||
private void SetAnalysing(bool busy)
|
|
||||||
{
|
|
||||||
AnalyseSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
AnalyseIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
AnalyseBtnText.Text = busy ? "Analysing\u2026" : "Identify & Price with AI";
|
|
||||||
AnalyseBtn.IsEnabled = !busy;
|
|
||||||
IsEnabled = !busy;
|
|
||||||
|
|
||||||
if (busy)
|
|
||||||
{
|
|
||||||
IdlePanel.Visibility = Visibility.Collapsed;
|
|
||||||
ResultsPanel.Visibility = Visibility.Collapsed;
|
|
||||||
LoadingPanel.Visibility = Visibility.Visible;
|
|
||||||
_loadingStep = 0;
|
|
||||||
LoadingStepText.Text = LoadingSteps[0];
|
|
||||||
_loadingTimer.Start();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_loadingTimer.Stop();
|
|
||||||
LoadingPanel.Visibility = Visibility.Collapsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsImageFile(string path)
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(path).ToLower();
|
|
||||||
return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
|
|
||||||
}
|
|
||||||
|
|
||||||
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
||||||
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks">
|
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
|
||||||
|
KeyboardNavigation.TabNavigation="Cycle">
|
||||||
|
|
||||||
<UserControl.Resources>
|
<UserControl.Resources>
|
||||||
|
|
||||||
@@ -41,6 +42,22 @@
|
|||||||
<Setter Property="Height" Value="30"/>
|
<Setter Property="Height" Value="30"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for detail action buttons -->
|
||||||
|
<Style x:Key="DetailActionButton" TargetType="Button"
|
||||||
|
BasedOn="{StaticResource MahApps.Styles.Button.Square.Accent}">
|
||||||
|
<Setter Property="Height" Value="34"/>
|
||||||
|
<Setter Property="Padding" Value="14,0"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,8,6"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Shared style for secondary detail action buttons -->
|
||||||
|
<Style x:Key="DetailSecondaryButton" TargetType="Button"
|
||||||
|
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
||||||
|
<Setter Property="Height" Value="34"/>
|
||||||
|
<Setter Property="Padding" Value="12,0"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,8,6"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
</UserControl.Resources>
|
</UserControl.Resources>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -51,7 +68,7 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
LEFT — Listings list
|
LEFT - Listings list
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<Grid Grid.Column="0">
|
<Grid Grid.Column="0">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -72,7 +89,8 @@
|
|||||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="14" Height="14"
|
<iconPacks:PackIconMaterial Kind="BookmarkMultiple" Width="14" Height="14"
|
||||||
Margin="0,0,7,0" VerticalAlignment="Center"
|
Margin="0,0,7,0" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||||
|
IsTabStop="False"/>
|
||||||
<TextBlock x:Name="ListingCountText" Text="0 saved listings"
|
<TextBlock x:Name="ListingCountText" Text="0 saved listings"
|
||||||
FontSize="12" FontWeight="SemiBold"
|
FontSize="12" FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray2}"
|
||||||
@@ -81,7 +99,8 @@
|
|||||||
<Button Grid.Column="1" x:Name="OpenExportsDirBtn"
|
<Button Grid.Column="1" x:Name="OpenExportsDirBtn"
|
||||||
Click="OpenExportsDir_Click"
|
Click="OpenExportsDir_Click"
|
||||||
Style="{StaticResource CardActionBtn}"
|
Style="{StaticResource CardActionBtn}"
|
||||||
ToolTip="Open exports folder in Explorer">
|
ToolTip="Open exports folder in Explorer"
|
||||||
|
AutomationProperties.Name="Open exports folder in Explorer">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="12" Height="12"
|
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="12" Height="12"
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
||||||
@@ -104,18 +123,20 @@
|
|||||||
Width="13" Height="13"
|
Width="13" Height="13"
|
||||||
Margin="0,0,7,0"
|
Margin="0,0,7,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
IsTabStop="False"/>
|
||||||
<TextBox Grid.Column="1" x:Name="SearchBox"
|
<TextBox Grid.Column="1" x:Name="SearchBox"
|
||||||
Style="{StaticResource SearchBox}"
|
Style="{StaticResource SearchBox}"
|
||||||
mah:TextBoxHelper.Watermark="Filter listings…"
|
mah:TextBoxHelper.Watermark="Filter listings..."
|
||||||
mah:TextBoxHelper.ClearTextButton="True"
|
mah:TextBoxHelper.ClearTextButton="True"
|
||||||
TextChanged="SearchBox_TextChanged"/>
|
TextChanged="SearchBox_TextChanged"
|
||||||
|
AutomationProperties.Name="Filter saved listings"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Card list -->
|
<!-- Card list -->
|
||||||
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
|
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto"
|
||||||
Padding="10,8">
|
Padding="10,8" Focusable="False">
|
||||||
<Grid>
|
<Grid>
|
||||||
<!-- Empty state for no saved listings -->
|
<!-- Empty state for no saved listings -->
|
||||||
<StackPanel x:Name="EmptyCardState"
|
<StackPanel x:Name="EmptyCardState"
|
||||||
@@ -128,11 +149,13 @@
|
|||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Margin="0,0,0,16"
|
Margin="0,0,0,16"
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray9}">
|
Background="{DynamicResource MahApps.Brushes.Gray9}">
|
||||||
|
|
||||||
<iconPacks:PackIconMaterial Kind="BookmarkPlusOutline"
|
<iconPacks:PackIconMaterial Kind="BookmarkPlusOutline"
|
||||||
Width="32" Height="32"
|
Width="32" Height="32"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
IsTabStop="False"/>
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Text="No saved listings yet"
|
<TextBlock Text="No saved listings yet"
|
||||||
FontSize="13" FontWeight="SemiBold"
|
FontSize="13" FontWeight="SemiBold"
|
||||||
@@ -158,7 +181,8 @@
|
|||||||
Width="36" Height="36"
|
Width="36" Height="36"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||||
Margin="0,0,0,12"/>
|
Margin="0,0,0,12"
|
||||||
|
IsTabStop="False"/>
|
||||||
<TextBlock Text="No listings match your search"
|
<TextBlock Text="No listings match your search"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
@@ -172,10 +196,11 @@
|
|||||||
|
|
||||||
<!-- Splitter -->
|
<!-- Splitter -->
|
||||||
<GridSplitter Grid.Column="1" Width="4" HorizontalAlignment="Stretch"
|
<GridSplitter Grid.Column="1" Width="4" HorizontalAlignment="Stretch"
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray8}"/>
|
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||||
|
AutomationProperties.Name="Resize listings panel"/>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
RIGHT — Detail panel
|
RIGHT - Detail panel
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
<Grid Grid.Column="2">
|
<Grid Grid.Column="2">
|
||||||
|
|
||||||
@@ -185,7 +210,8 @@
|
|||||||
<iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="48" Height="48"
|
<iconPacks:PackIconMaterial Kind="BookmarkOutline" Width="48" Height="48"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
||||||
Margin="0,0,0,14"/>
|
Margin="0,0,0,14"
|
||||||
|
IsTabStop="False"/>
|
||||||
<TextBlock Text="Select a saved listing" FontSize="14"
|
<TextBlock Text="Select a saved listing" FontSize="14"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
@@ -193,7 +219,8 @@
|
|||||||
|
|
||||||
<!-- Detail content -->
|
<!-- Detail content -->
|
||||||
<ScrollViewer x:Name="DetailPanel" Visibility="Collapsed" Opacity="0"
|
<ScrollViewer x:Name="DetailPanel" Visibility="Collapsed" Opacity="0"
|
||||||
VerticalScrollBarVisibility="Auto" Padding="18,14">
|
VerticalScrollBarVisibility="Auto" Padding="18,14"
|
||||||
|
Focusable="False">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<!-- Title + price row -->
|
<!-- Title + price row -->
|
||||||
@@ -205,29 +232,92 @@
|
|||||||
<TextBlock x:Name="DetailTitle" Grid.Column="0"
|
<TextBlock x:Name="DetailTitle" Grid.Column="0"
|
||||||
FontSize="17" FontWeight="Bold" TextWrapping="Wrap"
|
FontSize="17" FontWeight="Bold" TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray1}"/>
|
||||||
<Border Grid.Column="1"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Accent}"
|
<!-- Price display + quick revalue -->
|
||||||
CornerRadius="6" Padding="10,4" Margin="10,0,0,0"
|
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Top">
|
||||||
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"
|
<TextBlock x:Name="DetailPrice"
|
||||||
FontSize="16" FontWeight="Bold" Foreground="White"/>
|
FontSize="16" FontWeight="Bold" Foreground="White"/>
|
||||||
</Border>
|
</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"
|
||||||
|
AutomationProperties.Name="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"
|
||||||
|
AutomationProperties.Name="New price value"/>
|
||||||
|
<Button x:Name="CheckEbayBtn" Click="CheckEbayBtn_Click"
|
||||||
|
Height="30" Padding="8,0" Margin="6,0,4,0"
|
||||||
|
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||||
|
ToolTip="Check eBay for a suggested price"
|
||||||
|
AutomationProperties.Name="Check eBay for 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"
|
||||||
|
AutomationProperties.Name="Save new price">
|
||||||
|
<iconPacks:PackIconMaterial Kind="Check" Width="13" Height="13"/>
|
||||||
|
</Button>
|
||||||
|
<Button Click="RevalueCancel_Click"
|
||||||
|
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||||
|
Height="30" Padding="8,0"
|
||||||
|
ToolTip="Cancel"
|
||||||
|
AutomationProperties.Name="Cancel price change">
|
||||||
|
<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>
|
</Grid>
|
||||||
|
|
||||||
<!-- Meta row: category · date -->
|
<!-- Meta row: category / date -->
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
|
||||||
<iconPacks:PackIconMaterial Kind="Tag" Width="11" Height="11"
|
<iconPacks:PackIconMaterial Kind="Tag" Width="11" Height="11"
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"
|
Margin="0,0,4,0" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
IsTabStop="False"/>
|
||||||
<TextBlock x:Name="DetailCategory" FontSize="11"
|
<TextBlock x:Name="DetailCategory" FontSize="11"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<TextBlock Text=" · " FontSize="11"
|
<TextBlock Text=" | " FontSize="11"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray6}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<iconPacks:PackIconMaterial Kind="ClockOutline" Width="11" Height="11"
|
<iconPacks:PackIconMaterial Kind="ClockOutline" Width="11" Height="11"
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"
|
Margin="0,0,4,0" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
IsTabStop="False"/>
|
||||||
<TextBlock x:Name="DetailDate" FontSize="11"
|
<TextBlock x:Name="DetailDate" FontSize="11"
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
@@ -237,6 +327,7 @@
|
|||||||
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}"/>
|
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}"/>
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||||
VerticalScrollBarVisibility="Disabled"
|
VerticalScrollBarVisibility="Disabled"
|
||||||
|
Focusable="False"
|
||||||
Margin="0,0,0,4">
|
Margin="0,0,0,4">
|
||||||
<WrapPanel x:Name="DetailPhotosPanel" Orientation="Horizontal"/>
|
<WrapPanel x:Name="DetailPhotosPanel" Orientation="Horizontal"/>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
@@ -262,9 +353,34 @@
|
|||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<WrapPanel Orientation="Horizontal">
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<Button x:Name="PostDraftBtn"
|
||||||
|
Click="PostDraft_Click"
|
||||||
|
Style="{StaticResource DetailActionButton}"
|
||||||
|
ToolTip="Post this draft to eBay"
|
||||||
|
AutomationProperties.Name="Post draft to eBay">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial x:Name="PostDraftIcon"
|
||||||
|
Kind="CartArrowRight" Width="14" Height="14"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||||
|
<mah:ProgressRing x:Name="PostDraftSpinner"
|
||||||
|
Width="14" Height="14" Margin="0,0,6,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Click="EditListing_Click"
|
||||||
|
Style="{StaticResource DetailActionButton}"
|
||||||
|
AutomationProperties.Name="Edit this listing">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<iconPacks:PackIconMaterial Kind="Pencil" Width="13" Height="13"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Edit" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
<Button Click="OpenFolderDetail_Click"
|
<Button Click="OpenFolderDetail_Click"
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
Style="{StaticResource DetailActionButton}"
|
||||||
Height="34" Padding="14,0" Margin="0,0,8,6">
|
AutomationProperties.Name="Open export folder for this listing">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="13" Height="13"
|
<iconPacks:PackIconMaterial Kind="FolderOpen" Width="13" Height="13"
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
||||||
@@ -272,8 +388,8 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Click="CopyTitle_Click"
|
<Button Click="CopyTitle_Click"
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
Style="{StaticResource DetailSecondaryButton}"
|
||||||
Height="34" Padding="12,0" Margin="0,0,8,6">
|
AutomationProperties.Name="Copy listing title to clipboard">
|
||||||
<Button.Content>
|
<Button.Content>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
|
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
|
||||||
@@ -283,8 +399,8 @@
|
|||||||
</Button.Content>
|
</Button.Content>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Click="CopyDescription_Click"
|
<Button Click="CopyDescription_Click"
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
Style="{StaticResource DetailSecondaryButton}"
|
||||||
Height="34" Padding="12,0" Margin="0,0,8,6">
|
AutomationProperties.Name="Copy listing description to clipboard">
|
||||||
<Button.Content>
|
<Button.Content>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
|
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="12" Height="12"
|
||||||
@@ -294,8 +410,9 @@
|
|||||||
</Button.Content>
|
</Button.Content>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Click="DeleteListing_Click"
|
<Button Click="DeleteListing_Click"
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
Style="{StaticResource MahApps.Styles.Button.Square}"
|
||||||
Height="34" Padding="12,0" Margin="0,0,0,6">
|
Height="34" Padding="12,0" Margin="0,0,0,6"
|
||||||
|
AutomationProperties.Name="Delete this listing">
|
||||||
<Button.Content>
|
<Button.Content>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13"
|
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13"
|
||||||
@@ -311,6 +428,119 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Edit panel - shown in place of DetailPanel when editing -->
|
||||||
|
<ScrollViewer x:Name="EditPanel" Visibility="Collapsed"
|
||||||
|
VerticalScrollBarVisibility="Auto" Padding="18,14"
|
||||||
|
Focusable="False">
|
||||||
|
<StackPanel KeyboardNavigation.TabNavigation="Local">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<TextBlock x:Name="EditTitleLabel" Text="TITLE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
|
||||||
|
<TextBox x:Name="EditTitle" FontSize="13" Margin="0,0,0,4"
|
||||||
|
mah:TextBoxHelper.Watermark="Listing title"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=EditTitleLabel}"/>
|
||||||
|
|
||||||
|
<!-- Price + Category -->
|
||||||
|
<Grid Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="10"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<TextBlock x:Name="EditPriceLabel" Text="PRICE" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
|
||||||
|
<mah:NumericUpDown x:Name="EditPrice" Minimum="0" Maximum="99999"
|
||||||
|
StringFormat="F2" Interval="0.5" Value="0"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=EditPriceLabel}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2">
|
||||||
|
<TextBlock x:Name="EditCategoryLabel" Text="CATEGORY" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
|
||||||
|
<TextBox x:Name="EditCategory" FontSize="12"
|
||||||
|
mah:TextBoxHelper.Watermark="e.g. Clothing, Shoes & Accessories"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=EditCategoryLabel}"/>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Condition notes -->
|
||||||
|
<TextBlock x:Name="EditConditionLabel" Text="CONDITION NOTES" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
|
||||||
|
<TextBox x:Name="EditCondition" FontSize="12" Margin="0,0,0,4"
|
||||||
|
mah:TextBoxHelper.Watermark="Optional - e.g. minor scuff on base"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=EditConditionLabel}"/>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<TextBlock x:Name="EditDescriptionLabel" Text="DESCRIPTION" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
|
||||||
|
<TextBox x:Name="EditDescription" FontSize="12" Margin="0,0,0,4"
|
||||||
|
TextWrapping="Wrap" AcceptsReturn="True"
|
||||||
|
Height="130" VerticalScrollBarVisibility="Auto"
|
||||||
|
AutomationProperties.LabeledBy="{Binding ElementName=EditDescriptionLabel}"/>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
<TextBlock Text="PHOTOS" Style="{StaticResource DetailLabel}" Margin="0,0,0,3"/>
|
||||||
|
<TextBlock Text="First photo is the listing cover. Use arrows to reorder."
|
||||||
|
FontSize="10" Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
Margin="0,0,0,6"/>
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Disabled"
|
||||||
|
Focusable="False"
|
||||||
|
Margin="0,0,0,10">
|
||||||
|
<StackPanel x:Name="EditPhotosPanel" Orientation="Horizontal"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
AutomationProperties.Name="Save listing changes"/>
|
||||||
|
<Button x:Name="CancelEditBtn" Click="CancelEdit_Click"
|
||||||
|
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
||||||
|
Height="34" Padding="14,0"
|
||||||
|
Content="Cancel"
|
||||||
|
AutomationProperties.Name="Cancel editing"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Draft posted toast -->
|
||||||
|
<Border x:Name="DraftPostedToast"
|
||||||
|
Grid.Column="2"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="12" Padding="14,10"
|
||||||
|
CornerRadius="6"
|
||||||
|
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
||||||
|
BorderThickness="0,0,0,3"
|
||||||
|
BorderBrush="{DynamicResource MahApps.Brushes.Accent}"
|
||||||
|
Panel.ZIndex="10">
|
||||||
|
|
||||||
|
<Border.RenderTransform>
|
||||||
|
<TranslateTransform x:Name="ToastTranslate" Y="60"/>
|
||||||
|
</Border.RenderTransform>
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<iconPacks:PackIconMaterial Kind="CheckCircleOutline"
|
||||||
|
Width="16" Height="16" Margin="0,0,10,0"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Accent}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsTabStop="False"/>
|
||||||
|
<TextBlock x:Name="ToastUrlText" Grid.Column="1"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray1}" FontSize="12"/>
|
||||||
|
<Button Grid.Column="2" Content="x" Width="20" Height="20"
|
||||||
|
BorderThickness="0" Background="Transparent"
|
||||||
|
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
||||||
|
Click="DismissToast_Click" Margin="8,0,0,0"
|
||||||
|
AutomationProperties.Name="Dismiss notification"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ using System.Windows.Input;
|
|||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Animation;
|
using System.Windows.Media.Animation;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
using System.Windows.Threading;
|
||||||
using EbayListingTool.Models;
|
using EbayListingTool.Models;
|
||||||
using EbayListingTool.Services;
|
using EbayListingTool.Services;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
namespace EbayListingTool.Views;
|
namespace EbayListingTool.Views;
|
||||||
|
|
||||||
public partial class SavedListingsView : UserControl
|
public partial class SavedListingsView : UserControl
|
||||||
{
|
{
|
||||||
private SavedListingsService? _service;
|
private SavedListingsService? _service;
|
||||||
|
private PriceLookupService? _priceLookup;
|
||||||
|
private EbayListingService? _ebayListing;
|
||||||
|
private EbayAuthService? _ebayAuth;
|
||||||
private SavedListing? _selected;
|
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
|
// Normal card background — resolved once after load so we can restore it on mouse-leave
|
||||||
private Brush? _cardNormalBg;
|
private Brush? _cardNormalBg;
|
||||||
private Brush? _cardHoverBg;
|
private Brush? _cardHoverBg;
|
||||||
@@ -28,9 +37,15 @@ public partial class SavedListingsView : UserControl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialise(SavedListingsService service)
|
public void Initialise(SavedListingsService service,
|
||||||
|
PriceLookupService? priceLookup = null,
|
||||||
|
EbayListingService? ebayListing = null,
|
||||||
|
EbayAuthService? ebayAuth = null)
|
||||||
{
|
{
|
||||||
_service = service;
|
_service = service;
|
||||||
|
_priceLookup = priceLookup;
|
||||||
|
_ebayListing = ebayListing;
|
||||||
|
_ebayAuth = ebayAuth;
|
||||||
RefreshList();
|
RefreshList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +265,10 @@ public partial class SavedListingsView : UserControl
|
|||||||
EmptyDetail.Visibility = Visibility.Collapsed;
|
EmptyDetail.Visibility = Visibility.Collapsed;
|
||||||
DetailPanel.Visibility = Visibility.Visible;
|
DetailPanel.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
// Reset revalue UI
|
||||||
|
PriceDisplayRow.Visibility = Visibility.Visible;
|
||||||
|
RevalueRow.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
DetailTitle.Text = listing.Title;
|
DetailTitle.Text = listing.Title;
|
||||||
DetailPrice.Text = listing.PriceDisplay;
|
DetailPrice.Text = listing.PriceDisplay;
|
||||||
DetailCategory.Text = listing.Category;
|
DetailCategory.Text = listing.Category;
|
||||||
@@ -327,6 +346,379 @@ public partial class SavedListingsView : UserControl
|
|||||||
catch { }
|
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 ----
|
// ---- Button handlers ----
|
||||||
|
|
||||||
private void OpenExportsDir_Click(object sender, RoutedEventArgs e)
|
private void OpenExportsDir_Click(object sender, RoutedEventArgs e)
|
||||||
@@ -369,4 +761,92 @@ public partial class SavedListingsView : UserControl
|
|||||||
ClearDetail();
|
ClearDetail();
|
||||||
RefreshList();
|
RefreshList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void PostDraft_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_selected == null || _ebayListing == null || _ebayAuth == null) return;
|
||||||
|
if (!_ebayAuth.IsConnected)
|
||||||
|
{
|
||||||
|
MessageBox.Show("Please connect to eBay first.", "Not Connected",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetPostDraftBusy(true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var draft = _selected.ToListingDraft();
|
||||||
|
var url = await _ebayListing.PostListingAsync(draft);
|
||||||
|
|
||||||
|
var posted = _selected;
|
||||||
|
_selected = null;
|
||||||
|
_service?.Delete(posted);
|
||||||
|
ClearDetail();
|
||||||
|
RefreshList();
|
||||||
|
|
||||||
|
ToastUrlText.Text = url;
|
||||||
|
ShowDraftPostedToast();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Failed to post listing:\n\n{ex.Message}", "Post Failed",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SetPostDraftBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPostDraftBusy(bool busy)
|
||||||
|
{
|
||||||
|
PostDraftBtn.IsEnabled = !busy;
|
||||||
|
PostDraftIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
PostDraftSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
IsEnabled = !busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DispatcherTimer? _toastTimer;
|
||||||
|
|
||||||
|
private void ShowDraftPostedToast()
|
||||||
|
{
|
||||||
|
if (_toastTimer != null)
|
||||||
|
{
|
||||||
|
_toastTimer.Stop();
|
||||||
|
_toastTimer = null;
|
||||||
|
}
|
||||||
|
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, null);
|
||||||
|
DraftPostedToast.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
var slideIn = new System.Windows.Media.Animation.DoubleAnimation(
|
||||||
|
60, 0, new Duration(TimeSpan.FromMilliseconds(220)))
|
||||||
|
{
|
||||||
|
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||||
|
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }
|
||||||
|
};
|
||||||
|
slideIn.Completed += (_, _) =>
|
||||||
|
{
|
||||||
|
_toastTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(8) };
|
||||||
|
_toastTimer.Tick += (_, _) => DismissToastAnimated();
|
||||||
|
_toastTimer.Start();
|
||||||
|
};
|
||||||
|
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DismissToast_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> DismissToastAnimated();
|
||||||
|
|
||||||
|
private void DismissToastAnimated()
|
||||||
|
{
|
||||||
|
_toastTimer?.Stop();
|
||||||
|
_toastTimer = null;
|
||||||
|
var slideOut = new System.Windows.Media.Animation.DoubleAnimation(
|
||||||
|
0, 60, new Duration(TimeSpan.FromMilliseconds(180)))
|
||||||
|
{
|
||||||
|
EasingFunction = new System.Windows.Media.Animation.CubicEase
|
||||||
|
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseIn }
|
||||||
|
};
|
||||||
|
slideOut.Completed += (_, _) => DraftPostedToast.Visibility = Visibility.Collapsed;
|
||||||
|
ToastTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, slideOut);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,566 +0,0 @@
|
|||||||
<UserControl x:Class="EbayListingTool.Views.SingleItemView"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
|
|
||||||
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
|
|
||||||
Loaded="UserControl_Loaded">
|
|
||||||
|
|
||||||
<UserControl.Resources>
|
|
||||||
|
|
||||||
<!-- Section card — subtle bordered/shaded panel wrapping a group of fields -->
|
|
||||||
<Style x:Key="SectionCard" TargetType="Border">
|
|
||||||
<Setter Property="BorderThickness" Value="1"/>
|
|
||||||
<Setter Property="CornerRadius" Value="4"/>
|
|
||||||
<Setter Property="Padding" Value="14,12"/>
|
|
||||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource MahApps.Brushes.Gray8}"/>
|
|
||||||
<Setter Property="Background" Value="{DynamicResource MahApps.Brushes.Gray10}"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Upper-case accent section heading used inside each card -->
|
|
||||||
<Style x:Key="SectionHeading" TargetType="TextBlock">
|
|
||||||
<Setter Property="FontSize" Value="10"/>
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<Setter Property="Margin" Value="0,0,0,0"/>
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Standard field label inside a card -->
|
|
||||||
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="FontSize" Value="12"/>
|
|
||||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray2}"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Small red asterisk for required fields -->
|
|
||||||
<Style x:Key="RequiredAsterisk" TargetType="TextBlock">
|
|
||||||
<Setter Property="Text" Value=" *"/>
|
|
||||||
<Setter Property="FontWeight" Value="Bold"/>
|
|
||||||
<Setter Property="FontSize" Value="13"/>
|
|
||||||
<Setter Property="Foreground" Value="#E53935"/>
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
|
||||||
<Setter Property="Margin" Value="0,0,0,4"/>
|
|
||||||
<Setter Property="ToolTip" Value="Required"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- AI buttons: indigo-to-violet gradient, icon + label -->
|
|
||||||
<Style x:Key="AiButton" TargetType="Button"
|
|
||||||
BasedOn="{StaticResource MahApps.Styles.Button.Square}">
|
|
||||||
<Setter Property="Height" Value="28"/>
|
|
||||||
<Setter Property="Padding" Value="8,0"/>
|
|
||||||
<Setter Property="FontSize" Value="12"/>
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
<Setter Property="Foreground" Value="White"/>
|
|
||||||
<Setter Property="BorderThickness" Value="0"/>
|
|
||||||
<Setter Property="Background">
|
|
||||||
<Setter.Value>
|
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
|
||||||
<GradientStop Color="#7C3AED" Offset="0"/>
|
|
||||||
<GradientStop Color="#4F46E5" Offset="1"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
<Style.Triggers>
|
|
||||||
<Trigger Property="IsMouseOver" Value="True">
|
|
||||||
<Setter Property="Background">
|
|
||||||
<Setter.Value>
|
|
||||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
|
||||||
<GradientStop Color="#8B5CF6" Offset="0"/>
|
|
||||||
<GradientStop Color="#6366F1" Offset="1"/>
|
|
||||||
</LinearGradientBrush>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Trigger>
|
|
||||||
<Trigger Property="IsEnabled" Value="False">
|
|
||||||
<Setter Property="Opacity" Value="0.45"/>
|
|
||||||
</Trigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Primary post/action button -->
|
|
||||||
<Style x:Key="PostButton" TargetType="Button"
|
|
||||||
BasedOn="{StaticResource MahApps.Styles.Button.Square.Accent}">
|
|
||||||
<Setter Property="Height" Value="36"/>
|
|
||||||
<Setter Property="Padding" Value="20,0"/>
|
|
||||||
<Setter Property="FontSize" Value="13"/>
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
</UserControl.Resources>
|
|
||||||
|
|
||||||
<Grid Margin="16,12,16,12">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="8"/>
|
|
||||||
<ColumnDefinition Width="290"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- ================================================================
|
|
||||||
LEFT COLUMN — form fields grouped into section cards
|
|
||||||
================================================================ -->
|
|
||||||
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto">
|
|
||||||
<StackPanel>
|
|
||||||
|
|
||||||
<!-- LISTING DETAILS -->
|
|
||||||
<Border Style="{StaticResource SectionCard}">
|
|
||||||
<StackPanel>
|
|
||||||
|
|
||||||
<!-- Card header row -->
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
|
||||||
<iconPacks:PackIconMaterial Kind="TagOutline" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="LISTING DETAILS" Style="{StaticResource SectionHeading}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Title label row with required asterisk -->
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Title"/>
|
|
||||||
<TextBlock Style="{StaticResource RequiredAsterisk}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<!-- Title + AI Title button -->
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBox x:Name="TitleBox"
|
|
||||||
mah:TextBoxHelper.Watermark="Item title (max 80 characters)"
|
|
||||||
MaxLength="80"
|
|
||||||
TextChanged="TitleBox_TextChanged"/>
|
|
||||||
<Button Grid.Column="1" Margin="6,0,0,0"
|
|
||||||
Style="{StaticResource AiButton}"
|
|
||||||
Click="AiTitle_Click"
|
|
||||||
ToolTip="Ask Claude to write a keyword-rich eBay title based on your item details">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<mah:ProgressRing x:Name="TitleSpinner"
|
|
||||||
Width="11" Height="11" Margin="0,0,4,0"
|
|
||||||
Foreground="White" Visibility="Collapsed"/>
|
|
||||||
<iconPacks:PackIconMaterial x:Name="TitleAiIcon"
|
|
||||||
Kind="AutoFix" Width="12" Height="12"
|
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="AI Title" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Visual character-count progress bar -->
|
|
||||||
<Grid Margin="0,5,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<!-- Track -->
|
|
||||||
<Border Grid.Column="0" Height="4" CornerRadius="2"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
|
||||||
VerticalAlignment="Center" Margin="0,0,8,0">
|
|
||||||
<!-- Fill — width set in code-behind via TitleCountBar.Width -->
|
|
||||||
<Border x:Name="TitleCountBar" HorizontalAlignment="Left"
|
|
||||||
Height="4" CornerRadius="2" Width="0"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
</Border>
|
|
||||||
<TextBlock x:Name="TitleCount" Grid.Column="1"
|
|
||||||
Text="0 / 80" FontSize="10"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Category label row with required asterisk -->
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Category" Margin="0,0,0,4"/>
|
|
||||||
<TextBlock Style="{StaticResource RequiredAsterisk}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<!-- Category search -->
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBox x:Name="CategoryBox"
|
|
||||||
mah:TextBoxHelper.Watermark="Start typing to search categories..."
|
|
||||||
TextChanged="CategoryBox_TextChanged"
|
|
||||||
KeyDown="CategoryBox_KeyDown"/>
|
|
||||||
<Border Grid.Column="1" Margin="8,0,0,0" CornerRadius="3"
|
|
||||||
Padding="6,2" VerticalAlignment="Center"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray8}">
|
|
||||||
<TextBlock x:Name="CategoryIdLabel" FontSize="11"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray4}"
|
|
||||||
Text="no category"/>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
<!-- Category dropdown suggestions -->
|
|
||||||
<ListBox x:Name="CategorySuggestionsList" MaxHeight="140"
|
|
||||||
SelectionChanged="CategorySuggestionsList_SelectionChanged"
|
|
||||||
Visibility="Collapsed" Margin="0,2,0,0"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray9}"
|
|
||||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
|
|
||||||
BorderThickness="1">
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Text="{Binding CategoryPath}" FontSize="12" Padding="4,3"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
|
|
||||||
<!-- Condition + Format -->
|
|
||||||
<Grid Margin="0,10,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="12"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<StackPanel Grid.Column="0">
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Condition"/>
|
|
||||||
<ComboBox x:Name="ConditionBox"
|
|
||||||
SelectionChanged="ConditionBox_SelectionChanged">
|
|
||||||
<ComboBoxItem Content="New" Tag="New"
|
|
||||||
ToolTip="Brand new, unopened (eBay: New)"/>
|
|
||||||
<ComboBoxItem Content="Open Box" Tag="OpenBox"
|
|
||||||
ToolTip="Opened but unused (eBay: Open box)"/>
|
|
||||||
<ComboBoxItem Content="Refurbished" Tag="Refurbished"
|
|
||||||
ToolTip="Professionally restored (eBay: Seller refurbished)"/>
|
|
||||||
<ComboBoxItem Content="Used" Tag="Used" IsSelected="True"
|
|
||||||
ToolTip="Previously used, working (eBay: Used)"/>
|
|
||||||
<ComboBoxItem Content="For Parts / Not Working" Tag="ForParts"
|
|
||||||
ToolTip="Not fully working (eBay: For parts or not working)"/>
|
|
||||||
</ComboBox>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="2">
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Format"/>
|
|
||||||
<ComboBox x:Name="FormatBox">
|
|
||||||
<ComboBoxItem Content="Buy It Now" IsSelected="True"/>
|
|
||||||
<ComboBoxItem Content="Auction"/>
|
|
||||||
</ComboBox>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- DESCRIPTION -->
|
|
||||||
<Border Style="{StaticResource SectionCard}">
|
|
||||||
<StackPanel>
|
|
||||||
<Grid Margin="0,0,0,10">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="TextBox" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="DESCRIPTION" Style="{StaticResource SectionHeading}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<Button Grid.Column="1" Style="{StaticResource AiButton}"
|
|
||||||
Click="AiDescription_Click"
|
|
||||||
ToolTip="Ask Claude to write a full product description">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<mah:ProgressRing x:Name="DescSpinner"
|
|
||||||
Width="11" Height="11" Margin="0,0,4,0"
|
|
||||||
Foreground="White" Visibility="Collapsed"/>
|
|
||||||
<iconPacks:PackIconMaterial x:Name="DescAiIcon"
|
|
||||||
Kind="AutoFix" Width="12" Height="12"
|
|
||||||
Margin="0,0,4,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="AI Description" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
<TextBox x:Name="DescriptionBox" Height="150" AcceptsReturn="True"
|
|
||||||
TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
|
|
||||||
mah:TextBoxHelper.Watermark="Describe the item, condition, what's included..."
|
|
||||||
Style="{DynamicResource MahApps.Styles.TextBox}"
|
|
||||||
TextChanged="DescriptionBox_TextChanged"/>
|
|
||||||
|
|
||||||
<!-- Description character count progress bar (soft limit 2000) -->
|
|
||||||
<Grid Margin="0,5,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<Border Grid.Column="0" Height="4" CornerRadius="2"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray8}"
|
|
||||||
VerticalAlignment="Center" Margin="0,0,8,0">
|
|
||||||
<Border x:Name="DescCountBar" HorizontalAlignment="Left"
|
|
||||||
Height="4" CornerRadius="2" Width="0"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
</Border>
|
|
||||||
<TextBlock x:Name="DescCount" Grid.Column="1"
|
|
||||||
Text="0 / 2000" FontSize="10"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- PRICING & LOGISTICS -->
|
|
||||||
<Border Style="{StaticResource SectionCard}">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
|
||||||
<iconPacks:PackIconMaterial Kind="CurrencyGbp" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="PRICING & LOGISTICS" Style="{StaticResource SectionHeading}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="12"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="12"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- Price with inline AI button -->
|
|
||||||
<StackPanel Grid.Column="0">
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBlock Grid.Column="0" Style="{StaticResource FieldLabel}" Text="Price (£)"/>
|
|
||||||
<TextBlock Grid.Column="1" Style="{StaticResource RequiredAsterisk}"/>
|
|
||||||
<Button Grid.Column="3" Style="{StaticResource AiButton}"
|
|
||||||
Click="AiPrice_Click"
|
|
||||||
Height="22" Padding="6,0" FontSize="11"
|
|
||||||
Margin="4,0,0,4"
|
|
||||||
ToolTip="Ask Claude to suggest a competitive eBay UK price">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<mah:ProgressRing x:Name="PriceSpinner"
|
|
||||||
Width="9" Height="9" Margin="0,0,3,0"
|
|
||||||
Foreground="White" Visibility="Collapsed"/>
|
|
||||||
<iconPacks:PackIconMaterial x:Name="PriceAiIcon"
|
|
||||||
Kind="AutoFix" Width="10" Height="10"
|
|
||||||
Margin="0,0,3,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="AI Price" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
<mah:NumericUpDown x:Name="PriceBox" Minimum="0" Maximum="99999"
|
|
||||||
StringFormat="F2" Interval="0.5" Value="0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Grid.Column="2">
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Quantity"/>
|
|
||||||
<mah:NumericUpDown x:Name="QuantityBox" Minimum="1" Maximum="999"
|
|
||||||
Value="1" StringFormat="0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Grid.Column="4">
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Postage"/>
|
|
||||||
<ComboBox x:Name="PostageBox">
|
|
||||||
<ComboBoxItem Content="Royal Mail 1st Class (~£1.55)" IsSelected="True"/>
|
|
||||||
<ComboBoxItem Content="Royal Mail 2nd Class (~£1.20)"/>
|
|
||||||
<ComboBoxItem Content="Royal Mail Tracked 24 (~£2.90)"/>
|
|
||||||
<ComboBoxItem Content="Royal Mail Tracked 48 (~£2.60)"/>
|
|
||||||
<ComboBoxItem Content="Free Postage"/>
|
|
||||||
<ComboBoxItem Content="Collection Only"/>
|
|
||||||
</ComboBox>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Postcode — narrower input, left-aligned -->
|
|
||||||
<StackPanel Margin="0,10,0,0">
|
|
||||||
<TextBlock Style="{StaticResource FieldLabel}" Text="Item Postcode"/>
|
|
||||||
<TextBox x:Name="PostcodeBox"
|
|
||||||
mah:TextBoxHelper.Watermark="e.g. NR1 1AA"
|
|
||||||
MaxLength="10" Width="150"
|
|
||||||
HorizontalAlignment="Left"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- AI price suggestion result -->
|
|
||||||
<Border x:Name="PriceSuggestionPanel" Visibility="Collapsed"
|
|
||||||
CornerRadius="4" Margin="0,12,0,0" Padding="12,10"
|
|
||||||
Background="#1A7C3AED"
|
|
||||||
BorderBrush="#7C3AED" BorderThickness="1">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
|
||||||
<iconPacks:PackIconMaterial Kind="AutoFix" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="#7C3AED"/>
|
|
||||||
<TextBlock Text="AI Price Suggestion" FontWeight="Bold"
|
|
||||||
FontSize="12" Foreground="#7C3AED"/>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock x:Name="PriceSuggestionText" TextWrapping="Wrap"
|
|
||||||
FontSize="12" Margin="0,0,0,8"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
|
|
||||||
<Button Content="Use this price" HorizontalAlignment="Left"
|
|
||||||
Click="UseSuggestedPrice_Click"
|
|
||||||
Style="{StaticResource AiButton}"
|
|
||||||
Height="26" Padding="10,0" FontSize="11"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ACTION BUTTONS -->
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,2,0,8">
|
|
||||||
<!-- Post: primary accent + spinner overlay -->
|
|
||||||
<Button x:Name="PostBtn" Style="{StaticResource PostButton}"
|
|
||||||
Click="PostListing_Click">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<mah:ProgressRing x:Name="PostSpinner"
|
|
||||||
Width="14" Height="14" Margin="0,0,6,0"
|
|
||||||
Foreground="White" Visibility="Collapsed"/>
|
|
||||||
<iconPacks:PackIconMaterial x:Name="PostIcon"
|
|
||||||
Kind="Send" Width="14" Height="14"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button x:Name="SaveDraftBtn" Content="Save Draft"
|
|
||||||
Margin="8,0,0,0" Click="SaveDraft_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="36" Padding="14,0" FontSize="13"/>
|
|
||||||
<Button x:Name="NewListingBtn" Margin="8,0,0,0" Click="NewListing_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="36" Padding="14,0" FontSize="13">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="Refresh" Width="13" Height="13"
|
|
||||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Clear" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Posted success banner -->
|
|
||||||
<Border x:Name="SuccessPanel" Visibility="Collapsed"
|
|
||||||
CornerRadius="4" Padding="14,10"
|
|
||||||
Background="#1A4CAF50" BorderBrush="#4CAF50" BorderThickness="1">
|
|
||||||
<StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="CheckCircle" Width="16" Height="16"
|
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
|
||||||
Foreground="#4CAF50"/>
|
|
||||||
<TextBlock Text="Posted! " FontWeight="Bold" VerticalAlignment="Center"
|
|
||||||
Foreground="#4CAF50"/>
|
|
||||||
<TextBlock x:Name="ListingUrlText" Foreground="#1565C0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Cursor="Hand" TextDecorations="Underline"
|
|
||||||
MouseLeftButtonUp="ListingUrl_Click"/>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
|
|
||||||
<Button Content="Copy URL" Height="24" Padding="8,0" FontSize="11"
|
|
||||||
Click="CopyUrl_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
ToolTip="Copy listing URL to clipboard"/>
|
|
||||||
<Button Content="Copy Title" Height="24" Padding="8,0" FontSize="11"
|
|
||||||
Margin="6,0,0,0" Click="CopyTitle_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
ToolTip="Copy listing title to clipboard"/>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<!-- ================================================================
|
|
||||||
RIGHT COLUMN — photos panel
|
|
||||||
================================================================ -->
|
|
||||||
<Grid Grid.Column="2">
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="*"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- Header: label + count badge -->
|
|
||||||
<Grid Grid.Row="0" Margin="0,0,0,8">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="ImageMultiple" Width="13" Height="13"
|
|
||||||
Margin="0,0,6,0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Accent}"/>
|
|
||||||
<TextBlock Text="PHOTOS" Style="{StaticResource SectionHeading}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<!-- Photo count badge -->
|
|
||||||
<Border Grid.Column="1" CornerRadius="10" Padding="8,2"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray8}">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<TextBlock x:Name="PhotoCountBadge" Text="0"
|
|
||||||
FontSize="12" FontWeight="Bold"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
|
|
||||||
<TextBlock Text=" / 12" FontSize="12"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Drop zone with dashed border, hover highlight -->
|
|
||||||
<Border Grid.Row="1"
|
|
||||||
x:Name="DropZone"
|
|
||||||
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
|
|
||||||
BorderThickness="2" CornerRadius="4"
|
|
||||||
MinHeight="220"
|
|
||||||
AllowDrop="True" Drop="Photos_Drop" DragOver="Photos_DragOver"
|
|
||||||
DragEnter="DropZone_DragEnter" DragLeave="DropZone_DragLeave"
|
|
||||||
Background="{DynamicResource MahApps.Brushes.Gray10}">
|
|
||||||
<Border.Resources>
|
|
||||||
<!-- Dashed border via VisualBrush trickery is complex in WPF;
|
|
||||||
we use a solid thin border with hover accent colour instead -->
|
|
||||||
</Border.Resources>
|
|
||||||
<Grid>
|
|
||||||
<!-- Empty-state hint (hidden once photos added) -->
|
|
||||||
<StackPanel x:Name="DropHint" VerticalAlignment="Center"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
IsHitTestVisible="False">
|
|
||||||
<iconPacks:PackIconMaterial Kind="ImagePlus"
|
|
||||||
Width="40" Height="40"
|
|
||||||
Margin="0,0,0,8"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"/>
|
|
||||||
<TextBlock Text="Drag & drop photos here"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
|
|
||||||
FontSize="12" HorizontalAlignment="Center"/>
|
|
||||||
<TextBlock Text="or use Browse below"
|
|
||||||
Foreground="{DynamicResource MahApps.Brushes.Gray7}"
|
|
||||||
FontSize="11" HorizontalAlignment="Center" Margin="0,3,0,0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Thumbnails: each built in code-behind as a Grid with hover X overlay -->
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
|
||||||
HorizontalScrollBarVisibility="Disabled">
|
|
||||||
<WrapPanel x:Name="PhotosPanel" Margin="6"/>
|
|
||||||
</ScrollViewer>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Browse / Clear actions -->
|
|
||||||
<Grid Grid.Row="2" Margin="0,8,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
<ColumnDefinition Width="8"/>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<Button Grid.Column="0" Click="BrowsePhotos_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square.Accent}"
|
|
||||||
Height="30">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="FolderImage" Width="13" Height="13"
|
|
||||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Browse..." VerticalAlignment="Center" FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Grid.Column="2" Click="ClearPhotos_Click"
|
|
||||||
Style="{DynamicResource MahApps.Styles.Button.Square}"
|
|
||||||
Height="30" Padding="10,0">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<iconPacks:PackIconMaterial Kind="TrashCanOutline" Width="13" Height="13"
|
|
||||||
Margin="0,0,5,0" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Clear" VerticalAlignment="Center" FontSize="12"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using System.Windows;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Input;
|
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using EbayListingTool.Models;
|
|
||||||
using EbayListingTool.Services;
|
|
||||||
using Microsoft.Win32;
|
|
||||||
|
|
||||||
namespace EbayListingTool.Views;
|
|
||||||
|
|
||||||
public partial class SingleItemView : UserControl
|
|
||||||
{
|
|
||||||
private EbayListingService? _listingService;
|
|
||||||
private EbayCategoryService? _categoryService;
|
|
||||||
private AiAssistantService? _aiService;
|
|
||||||
private EbayAuthService? _auth;
|
|
||||||
|
|
||||||
private ListingDraft _draft = new();
|
|
||||||
private System.Threading.CancellationTokenSource? _categoryCts;
|
|
||||||
private string _suggestedPriceValue = "";
|
|
||||||
|
|
||||||
public SingleItemView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
PostcodeBox.TextChanged += (s, e) => _draft.Postcode = PostcodeBox.Text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UserControl_Loaded(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
// Re-run the count bar calculations now that the layout has rendered
|
|
||||||
// and the track Border has a non-zero ActualWidth.
|
|
||||||
TitleBox_TextChanged(this, null!);
|
|
||||||
DescriptionBox_TextChanged(this, null!);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Initialise(EbayListingService listingService, EbayCategoryService categoryService,
|
|
||||||
AiAssistantService aiService, EbayAuthService auth)
|
|
||||||
{
|
|
||||||
_listingService = listingService;
|
|
||||||
_categoryService = categoryService;
|
|
||||||
_aiService = aiService;
|
|
||||||
_auth = auth;
|
|
||||||
|
|
||||||
PostcodeBox.Text = App.Configuration["Ebay:DefaultPostcode"] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Pre-fills the form from a Photo Analysis result.</summary>
|
|
||||||
public void PopulateFromAnalysis(PhotoAnalysisResult result, IReadOnlyList<string> imagePaths, decimal price)
|
|
||||||
{
|
|
||||||
// Q6: reset form directly — calling NewListing_Click shows a confirmation dialog which
|
|
||||||
// is unexpected when arriving here automatically from the Photo Analysis tab.
|
|
||||||
_draft = new ListingDraft { Postcode = PostcodeBox.Text };
|
|
||||||
TitleBox.Text = "";
|
|
||||||
DescriptionBox.Text = "";
|
|
||||||
CategoryBox.Text = "";
|
|
||||||
CategoryIdLabel.Text = "(no category)";
|
|
||||||
PriceBox.Value = 0;
|
|
||||||
QuantityBox.Value = 1;
|
|
||||||
ConditionBox.SelectedIndex = 3; // Used
|
|
||||||
FormatBox.SelectedIndex = 0;
|
|
||||||
PhotosPanel.Children.Clear();
|
|
||||||
UpdatePhotoPanel();
|
|
||||||
SuccessPanel.Visibility = Visibility.Collapsed;
|
|
||||||
PriceSuggestionPanel.Visibility = Visibility.Collapsed;
|
|
||||||
|
|
||||||
TitleBox.Text = result.Title;
|
|
||||||
DescriptionBox.Text = result.Description;
|
|
||||||
PriceBox.Value = (double)price;
|
|
||||||
CategoryBox.Text = result.CategoryKeyword;
|
|
||||||
|
|
||||||
_draft.CategoryName = result.CategoryKeyword;
|
|
||||||
|
|
||||||
// Q1: load all photos from analysis
|
|
||||||
var validPaths = imagePaths.Where(p => !string.IsNullOrEmpty(p) && File.Exists(p)).ToArray();
|
|
||||||
if (validPaths.Length > 0)
|
|
||||||
AddPhotos(validPaths);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Title ----
|
|
||||||
|
|
||||||
private void TitleBox_TextChanged(object sender, TextChangedEventArgs e)
|
|
||||||
{
|
|
||||||
_draft.Title = TitleBox.Text;
|
|
||||||
var len = TitleBox.Text.Length;
|
|
||||||
TitleCount.Text = $"{len} / 80";
|
|
||||||
|
|
||||||
var overLimit = len > 75;
|
|
||||||
TitleCount.Foreground = overLimit
|
|
||||||
? System.Windows.Media.Brushes.OrangeRed
|
|
||||||
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
|
|
||||||
|
|
||||||
// Update the progress bar fill width proportionally
|
|
||||||
var trackBorder = TitleCountBar.Parent as Border;
|
|
||||||
double trackWidth = trackBorder?.ActualWidth ?? 0;
|
|
||||||
if (trackWidth > 0)
|
|
||||||
TitleCountBar.Width = trackWidth * (len / 80.0);
|
|
||||||
|
|
||||||
TitleCountBar.Background = overLimit
|
|
||||||
? System.Windows.Media.Brushes.OrangeRed
|
|
||||||
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void AiTitle_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_aiService == null) return;
|
|
||||||
var condition = GetSelectedCondition().ToString();
|
|
||||||
var current = TitleBox.Text;
|
|
||||||
|
|
||||||
SetTitleSpinner(true);
|
|
||||||
SetBusy(true, "Generating title...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var title = await _aiService.GenerateTitleAsync(current, condition);
|
|
||||||
TitleBox.Text = title.Trim().TrimEnd('.').Trim('"');
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ShowError("AI Title", ex.Message);
|
|
||||||
}
|
|
||||||
finally { SetBusy(false); SetTitleSpinner(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Category ----
|
|
||||||
|
|
||||||
private async void CategoryBox_TextChanged(object sender, TextChangedEventArgs e)
|
|
||||||
{
|
|
||||||
_categoryCts?.Cancel();
|
|
||||||
_categoryCts?.Dispose();
|
|
||||||
_categoryCts = new System.Threading.CancellationTokenSource();
|
|
||||||
var cts = _categoryCts;
|
|
||||||
|
|
||||||
if (CategoryBox.Text.Length < 3)
|
|
||||||
{
|
|
||||||
CategorySuggestionsList.Visibility = Visibility.Collapsed;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.Delay(400, cts.Token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cts.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var suggestions = await _categoryService!.GetCategorySuggestionsAsync(CategoryBox.Text);
|
|
||||||
if (cts.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
Dispatcher.Invoke(() =>
|
|
||||||
{
|
|
||||||
CategorySuggestionsList.ItemsSource = suggestions;
|
|
||||||
CategorySuggestionsList.Visibility = suggestions.Count > 0
|
|
||||||
? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) { /* superseded by newer keystroke */ }
|
|
||||||
catch { /* ignore transient network errors */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DescriptionBox_TextChanged(object sender, TextChangedEventArgs e)
|
|
||||||
{
|
|
||||||
_draft.Description = DescriptionBox.Text;
|
|
||||||
var len = DescriptionBox.Text.Length;
|
|
||||||
var softCap = 2000;
|
|
||||||
DescCount.Text = $"{len} / {softCap}";
|
|
||||||
|
|
||||||
var overLimit = len > softCap;
|
|
||||||
DescCount.Foreground = overLimit
|
|
||||||
? System.Windows.Media.Brushes.OrangeRed
|
|
||||||
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray5");
|
|
||||||
|
|
||||||
var trackBorder = DescCountBar.Parent as Border;
|
|
||||||
double trackWidth = trackBorder?.ActualWidth ?? 0;
|
|
||||||
if (trackWidth > 0)
|
|
||||||
DescCountBar.Width = Math.Min(trackWidth, trackWidth * (len / (double)softCap));
|
|
||||||
|
|
||||||
DescCountBar.Background = overLimit
|
|
||||||
? System.Windows.Media.Brushes.OrangeRed
|
|
||||||
: new System.Windows.Media.SolidColorBrush(
|
|
||||||
System.Windows.Media.Color.FromRgb(0xF5, 0x9E, 0x0B)); // amber
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CategoryBox_KeyDown(object sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key == Key.Escape)
|
|
||||||
{
|
|
||||||
CategorySuggestionsList.Visibility = Visibility.Collapsed;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CategorySuggestionsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (CategorySuggestionsList.SelectedItem is CategorySuggestion cat)
|
|
||||||
{
|
|
||||||
_draft.CategoryId = cat.CategoryId;
|
|
||||||
_draft.CategoryName = cat.CategoryName;
|
|
||||||
CategoryBox.Text = cat.CategoryName;
|
|
||||||
CategoryIdLabel.Text = $"ID: {cat.CategoryId}";
|
|
||||||
CategorySuggestionsList.Visibility = Visibility.Collapsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Condition ----
|
|
||||||
|
|
||||||
private void ConditionBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
_draft.Condition = GetSelectedCondition();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ItemCondition GetSelectedCondition()
|
|
||||||
{
|
|
||||||
var tag = (ConditionBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Used";
|
|
||||||
return tag switch
|
|
||||||
{
|
|
||||||
"New" => ItemCondition.New,
|
|
||||||
"OpenBox" => ItemCondition.OpenBox,
|
|
||||||
"Refurbished" => ItemCondition.Refurbished,
|
|
||||||
"ForParts" => ItemCondition.ForPartsOrNotWorking,
|
|
||||||
_ => ItemCondition.Used
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Description ----
|
|
||||||
|
|
||||||
private async void AiDescription_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_aiService == null) return;
|
|
||||||
SetDescSpinner(true);
|
|
||||||
SetBusy(true, "Writing description...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var description = await _aiService.WriteDescriptionAsync(
|
|
||||||
TitleBox.Text, GetSelectedCondition().ToString(), DescriptionBox.Text);
|
|
||||||
DescriptionBox.Text = description;
|
|
||||||
}
|
|
||||||
catch (Exception ex) { ShowError("AI Description", ex.Message); }
|
|
||||||
finally { SetBusy(false); SetDescSpinner(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Price ----
|
|
||||||
|
|
||||||
private async void AiPrice_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_aiService == null) return;
|
|
||||||
SetPriceSpinner(true);
|
|
||||||
SetBusy(true, "Researching price...");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _aiService.SuggestPriceAsync(
|
|
||||||
TitleBox.Text, GetSelectedCondition().ToString());
|
|
||||||
PriceSuggestionText.Text = result;
|
|
||||||
|
|
||||||
// Extract price line for "Use this price"
|
|
||||||
var lines = result.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var priceLine = lines.FirstOrDefault(l => l.StartsWith("PRICE:", StringComparison.OrdinalIgnoreCase));
|
|
||||||
_suggestedPriceValue = priceLine?.Replace("PRICE:", "", StringComparison.OrdinalIgnoreCase).Trim() ?? "";
|
|
||||||
|
|
||||||
PriceSuggestionPanel.Visibility = Visibility.Visible;
|
|
||||||
}
|
|
||||||
catch (Exception ex) { ShowError("AI Price", ex.Message); }
|
|
||||||
finally { SetBusy(false); SetPriceSpinner(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UseSuggestedPrice_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (decimal.TryParse(_suggestedPriceValue, out var price))
|
|
||||||
PriceBox.Value = (double)price;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Photos ----
|
|
||||||
|
|
||||||
private void Photos_DragOver(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
|
|
||||||
? DragDropEffects.Copy : DragDropEffects.None;
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Photos_Drop(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
|
||||||
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
|
|
||||||
// Remove highlight when drop completes
|
|
||||||
DropZone.BorderBrush = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray7");
|
|
||||||
DropZone.Background = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray10");
|
|
||||||
AddPhotos(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DropZone_DragEnter(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
|
|
||||||
DropZone.BorderBrush = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
|
|
||||||
DropZone.Background = new System.Windows.Media.SolidColorBrush(
|
|
||||||
System.Windows.Media.Color.FromArgb(20, 0x5C, 0x6B, 0xC0)); // subtle indigo tint
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DropZone_DragLeave(object sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
DropZone.BorderBrush = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray7");
|
|
||||||
DropZone.Background = (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray10");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void BrowsePhotos_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var dlg = new OpenFileDialog
|
|
||||||
{
|
|
||||||
Title = "Select photos",
|
|
||||||
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.bmp|All files|*.*",
|
|
||||||
Multiselect = true
|
|
||||||
};
|
|
||||||
if (dlg.ShowDialog() == true)
|
|
||||||
AddPhotos(dlg.FileNames);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddPhotos(string[] paths)
|
|
||||||
{
|
|
||||||
var imageExts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{ ".jpg", ".jpeg", ".png", ".gif", ".bmp" };
|
|
||||||
|
|
||||||
foreach (var path in paths)
|
|
||||||
{
|
|
||||||
if (!imageExts.Contains(Path.GetExtension(path))) continue;
|
|
||||||
if (_draft.PhotoPaths.Count >= 12) break;
|
|
||||||
if (_draft.PhotoPaths.Contains(path)) continue;
|
|
||||||
|
|
||||||
_draft.PhotoPaths.Add(path);
|
|
||||||
AddPhotoThumbnail(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdatePhotoPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddPhotoThumbnail(string path)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var bmp = new BitmapImage();
|
|
||||||
bmp.BeginInit();
|
|
||||||
bmp.UriSource = new Uri(path, UriKind.Absolute); // W1
|
|
||||||
bmp.DecodePixelWidth = 128;
|
|
||||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
|
||||||
bmp.EndInit();
|
|
||||||
bmp.Freeze(); // M2
|
|
||||||
|
|
||||||
var img = new System.Windows.Controls.Image
|
|
||||||
{
|
|
||||||
Width = 72, Height = 72,
|
|
||||||
Stretch = System.Windows.Media.Stretch.UniformToFill,
|
|
||||||
Source = bmp,
|
|
||||||
ToolTip = Path.GetFileName(path)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
|
||||||
var removeBtn = new Button
|
|
||||||
{
|
|
||||||
Width = 18, Height = 18,
|
|
||||||
Cursor = Cursors.Hand,
|
|
||||||
ToolTip = "Remove photo",
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right,
|
|
||||||
VerticalAlignment = VerticalAlignment.Top,
|
|
||||||
Margin = new Thickness(0, 2, 2, 0),
|
|
||||||
Padding = new Thickness(0),
|
|
||||||
Background = new System.Windows.Media.SolidColorBrush(
|
|
||||||
System.Windows.Media.Color.FromArgb(200, 30, 30, 30)),
|
|
||||||
Foreground = System.Windows.Media.Brushes.White,
|
|
||||||
BorderThickness = new Thickness(0),
|
|
||||||
FontSize = 11,
|
|
||||||
FontWeight = FontWeights.Bold,
|
|
||||||
Content = "✕",
|
|
||||||
Opacity = 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Container grid — shows remove button on mouse over
|
|
||||||
var container = new Grid
|
|
||||||
{
|
|
||||||
Width = 72, Height = 72,
|
|
||||||
Margin = new Thickness(4),
|
|
||||||
Cursor = Cursors.Hand
|
|
||||||
};
|
|
||||||
container.Children.Add(img);
|
|
||||||
container.Children.Add(removeBtn);
|
|
||||||
|
|
||||||
container.MouseEnter += (s, e) => removeBtn.Opacity = 1;
|
|
||||||
container.MouseLeave += (s, e) => removeBtn.Opacity = 0;
|
|
||||||
removeBtn.Click += (s, e) => RemovePhoto(path, container);
|
|
||||||
|
|
||||||
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;
|
|
||||||
DropHint.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
PhotoCountBadge.Text = count.ToString();
|
|
||||||
// Tint the badge red when at the limit
|
|
||||||
PhotoCountBadge.Foreground = count >= 12
|
|
||||||
? System.Windows.Media.Brushes.OrangeRed
|
|
||||||
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Gray2");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearPhotos_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_draft.PhotoPaths.Clear();
|
|
||||||
PhotosPanel.Children.Clear();
|
|
||||||
UpdatePhotoPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Post / Save ----
|
|
||||||
|
|
||||||
private async void PostListing_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!ValidateDraft()) return;
|
|
||||||
|
|
||||||
_draft.Title = TitleBox.Text.Trim();
|
|
||||||
_draft.Description = DescriptionBox.Text.Trim();
|
|
||||||
_draft.Price = (decimal)(PriceBox.Value ?? 0);
|
|
||||||
_draft.Quantity = (int)(QuantityBox.Value ?? 1);
|
|
||||||
_draft.Condition = GetSelectedCondition();
|
|
||||||
_draft.Format = FormatBox.SelectedIndex == 0 ? ListingFormat.FixedPrice : ListingFormat.Auction;
|
|
||||||
_draft.Postcode = PostcodeBox.Text;
|
|
||||||
|
|
||||||
SetPostSpinner(true);
|
|
||||||
SetBusy(true, "Posting to eBay...");
|
|
||||||
PostBtn.IsEnabled = false;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = await _listingService!.PostListingAsync(_draft);
|
|
||||||
ListingUrlText.Text = url;
|
|
||||||
SuccessPanel.Visibility = Visibility.Visible;
|
|
||||||
GetWindow()?.SetStatus($"Listed: {_draft.Title}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
ShowError("Post Failed", ex.Message);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
SetBusy(false);
|
|
||||||
SetPostSpinner(false);
|
|
||||||
PostBtn.IsEnabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ListingUrl_Click(object sender, MouseButtonEventArgs e)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(_draft.EbayListingUrl))
|
|
||||||
Process.Start(new ProcessStartInfo(_draft.EbayListingUrl) { UseShellExecute = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CopyUrl_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var url = ListingUrlText.Text;
|
|
||||||
if (!string.IsNullOrEmpty(url))
|
|
||||||
System.Windows.Clipboard.SetText(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CopyTitle_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(_draft.Title))
|
|
||||||
System.Windows.Clipboard.SetText(_draft.Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SaveDraft_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
// Drafts: future feature — for now just confirm save
|
|
||||||
MessageBox.Show("Draft saved (local save to be implemented in a future update).",
|
|
||||||
"Save Draft", MessageBoxButton.OK, MessageBoxImage.Information);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NewListing_Click(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(TitleBox.Text))
|
|
||||||
{
|
|
||||||
var result = MessageBox.Show(
|
|
||||||
"Start a new listing? Current details will be lost.",
|
|
||||||
"New Listing",
|
|
||||||
MessageBoxButton.OKCancel,
|
|
||||||
MessageBoxImage.Question);
|
|
||||||
if (result != MessageBoxResult.OK) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_draft = new ListingDraft { Postcode = PostcodeBox.Text };
|
|
||||||
TitleBox.Text = "";
|
|
||||||
DescriptionBox.Text = "";
|
|
||||||
CategoryBox.Text = "";
|
|
||||||
CategoryIdLabel.Text = "(no category)";
|
|
||||||
PriceBox.Value = 0;
|
|
||||||
QuantityBox.Value = 1;
|
|
||||||
ConditionBox.SelectedIndex = 3; // Used
|
|
||||||
FormatBox.SelectedIndex = 0;
|
|
||||||
PhotosPanel.Children.Clear();
|
|
||||||
UpdatePhotoPanel();
|
|
||||||
SuccessPanel.Visibility = Visibility.Collapsed;
|
|
||||||
PriceSuggestionPanel.Visibility = Visibility.Collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
|
||||||
|
|
||||||
private bool ValidateDraft()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(TitleBox.Text))
|
|
||||||
{ ShowError("Validation", "Please enter a title."); return false; }
|
|
||||||
if (TitleBox.Text.Length > 80)
|
|
||||||
{ ShowError("Validation", "Title must be 80 characters or fewer."); return false; }
|
|
||||||
if (string.IsNullOrEmpty(_draft.CategoryId))
|
|
||||||
{ ShowError("Validation", "Please select a category."); return false; }
|
|
||||||
if ((PriceBox.Value ?? 0) <= 0)
|
|
||||||
{ ShowError("Validation", "Please enter a price greater than zero."); return false; }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetBusy(bool busy, string message = "")
|
|
||||||
{
|
|
||||||
IsEnabled = !busy;
|
|
||||||
GetWindow()?.SetStatus(busy ? message : "Ready");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetPostSpinner(bool spinning)
|
|
||||||
{
|
|
||||||
PostSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
PostIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetTitleSpinner(bool spinning)
|
|
||||||
{
|
|
||||||
TitleSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
TitleAiIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetDescSpinner(bool spinning)
|
|
||||||
{
|
|
||||||
DescSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
DescAiIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetPriceSpinner(bool spinning)
|
|
||||||
{
|
|
||||||
PriceSpinner.Visibility = spinning ? Visibility.Visible : Visibility.Collapsed;
|
|
||||||
PriceAiIcon.Visibility = spinning ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowError(string title, string message)
|
|
||||||
=> MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
||||||
|
|
||||||
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
|
|
||||||
}
|
|
||||||
BIN
EbayListingTool/app_icon.ico
Normal file
BIN
EbayListingTool/app_icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 284 B |
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