Compare commits

31 Commits

Author SHA1 Message Date
Peter Foster
d9072a6018 fix: Content-Language header on offer creation; double photo thumbnails; sync Windows diverged files
- Add Content-Language: en-US to CreateOfferAsync (eBay Inventory API requires it)
- Double thumbnail sizes in NewListingView (96→192, 100→200)
- Sync NewListingView.xaml/.cs from Windows (replaces SingleItemView)
- Sync latest AiAssistantService, SavedListingsService, MainWindow, SavedListingsView from Windows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:59:15 +01:00
Peter Foster
61bbe86323 fix: use Inventory API condition enum strings instead of legacy numeric IDs
eBay Inventory API rejects numeric condition IDs (e.g. "3000") and requires
string enums (e.g. "USED_VERY_GOOD"). Was causing 400 errors on every post attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 12:52:15 +01:00
Peter Foster
3e476cbdf1 fix: pass empty string for conditionNotes in Save(ListingDraft) overload 2026-04-15 09:59:52 +01:00
Peter Foster
4cf5f11c9f feat: Relist button pre-populates form with aspects and shipping from saved listing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:57:32 +01:00
Peter Foster
18bd3b910f feat: show aspects and shipping in saved listings detail panel
Adds postage/shipping-cost display, item specifics (aspects) panel, and
Relist button stub to the SavedListingsView detail panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:55:30 +01:00
Peter Foster
40c62dbf34 feat: auto-save listing to archive after successful eBay post 2026-04-15 09:53:01 +01:00
Peter Foster
8564c3d51c feat: add Save(ListingDraft) overload capturing aspects and shipping 2026-04-15 09:51:59 +01:00
Peter Foster
52f257b5ad feat: add CategoryId, Condition, Postage, ShippingCost, Aspects to SavedListing 2026-04-15 09:50:57 +01:00
Peter Foster
e03fc0a49c fix: handle aspects load errors gracefully and preserve user values on category reselect 2026-04-15 09:42:31 +01:00
Peter Foster
d4dd11ed3e feat: pass item aspects to eBay and set shipping defaults on bulk import
- Task 8: Convert aspects Dictionary<string,string> to Dictionary<string,string[]> in CreateInventoryItemAsync
- Task 9: Set Postage to RoyalMailTracked48 and ShippingCost to 3.49m on BulkImportRow.ToListingDraft()
2026-04-15 09:39:11 +01:00
Peter Foster
c9bdb6f7fe feat: create fulfillment policies on-demand per postage option and cost
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:37:25 +01:00
Peter Foster
a22e11b2f7 feat: wire aspects panel and shipping cost in SingleItemView code-behind
Implements LoadAspectsAsync/RebuildAspectFields, real PostageBox and AiAspects
handlers, ShippingCostBox wiring, required-aspects validation, and aspects
reset on new listing. Also registers EbayAspectsService in MainWindow (Task 6).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:34:07 +01:00
Peter Foster
f7b34b6a75 feat: add aspects panel and shipping cost field to SingleItemView XAML
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:30:02 +01:00
Peter Foster
0ae47e9427 feat: add SuggestAspectsAsync to AiAssistantService 2026-04-15 09:27:43 +01:00
Peter Foster
e70fb9ee5c feat: add EbayAspectsService with Taxonomy API aspect fetching
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:26:38 +01:00
Peter Foster
af6f3f9234 feat: add Aspects dict and ShippingCost to ListingDraft 2026-04-15 09:25:13 +01:00
Peter Foster
a9cfb7f613 Fix NullReferenceException in PriceSliderCard_ValueChanged
Slider ValueChanged fires during XAML init before named controls exist.
Guard with IsLoaded check, same pattern as UpdateCardTitleBar.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 03:00:36 +01:00
Peter Foster
edbce97a74 Add .gitattributes to normalise line endings
Fixes Windows git showing modified files after Linux commits.
LF stored in repo; CRLF on Windows checkout for all source files.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Removed placeholder ExternalPictureURL from Trading API SOAP body.

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

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

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

19
.gitattributes vendored Normal file
View File

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

2
.gitignore vendored
View File

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

View File

@@ -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);

View File

@@ -24,6 +24,7 @@
<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>

View File

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

View File

@@ -124,7 +124,9 @@ public class BulkImportRow : INotifyPropertyChanged
PhotoPaths = PhotoPaths.Split(',', StringSplitOptions.RemoveEmptyEntries) PhotoPaths = PhotoPaths.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim()) .Select(x => x.Trim())
.Where(x => !string.IsNullOrEmpty(x)) .Where(x => !string.IsNullOrEmpty(x))
.ToList() .ToList(),
Postage = PostageOption.RoyalMailTracked48,
ShippingCost = 3.49m
}; };
} }

View File

@@ -37,6 +37,8 @@ public class ListingDraft : INotifyPropertyChanged
private ItemCondition _condition = ItemCondition.Used; private ItemCondition _condition = ItemCondition.Used;
private ListingFormat _format = ListingFormat.FixedPrice; private ListingFormat _format = ListingFormat.FixedPrice;
private PostageOption _postage = PostageOption.RoyalMailSecondClass; private PostageOption _postage = PostageOption.RoyalMailSecondClass;
private Dictionary<string, string> _aspects = new();
private decimal _shippingCost;
private string _categoryId = ""; private string _categoryId = "";
private string _categoryName = ""; private string _categoryName = "";
private string _postcode = ""; private string _postcode = "";
@@ -92,6 +94,18 @@ public class ListingDraft : INotifyPropertyChanged
set { _postage = value; OnPropertyChanged(); } set { _postage = value; OnPropertyChanged(); }
} }
public Dictionary<string, string> Aspects
{
get => _aspects;
set { _aspects = value; OnPropertyChanged(); }
}
public decimal ShippingCost
{
get => _shippingCost;
set { _shippingCost = value; OnPropertyChanged(); }
}
public string CategoryId public string CategoryId
{ {
get => _categoryId; get => _categoryId;
@@ -150,12 +164,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 => "NEW_OTHER",
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;

View File

@@ -1,4 +1,4 @@
namespace EbayListingTool.Models; namespace EbayListingTool.Models;
public class PhotoAnalysisResult public class PhotoAnalysisResult
{ {
@@ -18,6 +18,6 @@ public class PhotoAnalysisResult
public string PriceRangeDisplay => public string PriceRangeDisplay =>
PriceMin > 0 && PriceMax > 0 PriceMin > 0 && PriceMax > 0
? $"£{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}" : "";
} }

View File

@@ -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
};
} }

View File

@@ -1,4 +1,4 @@
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using EbayListingTool.Models; using EbayListingTool.Models;
@@ -65,7 +65,7 @@ public class AiAssistantService
string priceContext = ""; string priceContext = "";
if (soldPrices != null && soldPrices.Any()) if (soldPrices != null && soldPrices.Any())
{ {
var prices = soldPrices.Select(p => $"£{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);

View File

@@ -0,0 +1,74 @@
using System.Net.Http.Headers;
using Newtonsoft.Json.Linq;
namespace EbayListingTool.Services;
public class CategoryAspect
{
public string Name { get; set; } = "";
public bool IsRequired { get; set; }
public bool IsFreeText { get; set; } = true;
public List<string> AllowedValues { get; set; } = new();
}
public class EbayAspectsService
{
private readonly EbayAuthService _auth;
private static readonly HttpClient _http = new();
private readonly Dictionary<string, List<CategoryAspect>> _cache = new();
public EbayAspectsService(EbayAuthService auth) => _auth = auth;
public async Task<List<CategoryAspect>> GetAspectsAsync(string categoryId)
{
if (string.IsNullOrWhiteSpace(categoryId)) return new();
if (_cache.TryGetValue(categoryId, out var cached)) return cached;
var token = await _auth.GetAppTokenAsync();
var url = $"{_auth.BaseUrl}/commerce/taxonomy/v1/category_tree/3" +
$"/get_item_aspects_for_category?category_id={Uri.EscapeDataString(categoryId)}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) return new();
var aspects = new List<CategoryAspect>();
var arr = JObject.Parse(json)["aspects"] as JArray;
if (arr == null) { _cache[categoryId] = aspects; return aspects; }
foreach (var item in arr)
{
var constraint = item["aspectConstraint"];
if (constraint == null) continue;
var required = constraint["aspectRequired"]?.Value<bool>() ?? false;
var usage = constraint["aspectUsage"]?.ToString() ?? "";
if (!required && usage != "RECOMMENDED") continue;
var aspect = new CategoryAspect
{
Name = item["localizedAspectName"]?.ToString() ?? "",
IsRequired = required,
IsFreeText = constraint["aspectMode"]?.ToString() != "SELECTION_ONLY"
};
var values = item["aspectValues"] as JArray;
if (values != null)
aspect.AllowedValues = values
.Select(v => v["localizedValue"]?.ToString() ?? "")
.Where(v => !string.IsNullOrEmpty(v))
.Take(50)
.ToList();
if (!string.IsNullOrEmpty(aspect.Name))
aspects.Add(aspect);
}
_cache[categoryId] = aspects;
return aspects;
}
}

View File

@@ -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);

View File

@@ -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>();

View File

@@ -12,17 +12,68 @@ 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)
// 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 readonly Dictionary<string, string> _policyCache = new();
private static readonly string PolicyCacheFile =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EbayListingTool", "fulfillment_policies.json");
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService) public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
{ {
_auth = auth; _auth = auth;
_categoryService = categoryService; _categoryService = categoryService;
LoadPolicyCacheFromDisk();
}
/// <summary>Call when the user disconnects so stale IDs are not reused after re-login.</summary>
public void ClearCache()
{
_paymentPolicyId = null;
_returnPolicyId = null;
_merchantLocationKey = null;
}
private void LoadPolicyCacheFromDisk()
{
try
{
if (!File.Exists(PolicyCacheFile)) return;
var json = File.ReadAllText(PolicyCacheFile);
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
if (dict != null)
foreach (var kv in dict) _policyCache[kv.Key] = kv.Value;
}
catch { /* ignore corrupt cache */ }
}
private void SavePolicyCacheToDisk()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(PolicyCacheFile)!);
File.WriteAllText(PolicyCacheFile, JsonConvert.SerializeObject(_policyCache));
}
catch { /* non-critical */ }
} }
public async Task<string> PostListingAsync(ListingDraft draft) public async Task<string> PostListingAsync(ListingDraft draft)
{ {
var token = await _auth.GetValidAccessTokenAsync(); var token = await _auth.GetValidAccessTokenAsync();
// 1. Upload photos and get URLs // Resolve business policies and merchant location before touching inventory/offers
await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
_fulfillmentPolicyId = await GetOrCreateFulfillmentPolicyAsync(draft.Postage, draft.ShippingCost, token);
// 1. Upload photos and get eBay-hosted URLs
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token); var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
// 2. Resolve category if not set // 2. Resolve category if not set
@@ -51,31 +102,224 @@ public class EbayListingService
return draft.EbayListingUrl; return draft.EbayListingUrl;
} }
// ---- Fulfillment policy: on-demand creation ----
private static string ToShippingServiceCode(PostageOption option) => option switch
{
PostageOption.RoyalMailFirstClass => "UK_RoyalMailFirstClassStandard",
PostageOption.RoyalMailSecondClass => "UK_RoyalMailSecondClassStandard",
PostageOption.RoyalMailTracked24 => "UK_RoyalMailTracked24",
PostageOption.RoyalMailTracked48 => "UK_RoyalMailTracked48",
PostageOption.CollectionOnly => "UK_CollectInPerson",
PostageOption.FreePostage => "UK_RoyalMailSecondClassStandard",
_ => "UK_RoyalMailSecondClassStandard"
};
private async Task<string> GetOrCreateFulfillmentPolicyAsync(
PostageOption postage, decimal shippingCost, string token)
{
var free = postage == PostageOption.FreePostage || postage == PostageOption.CollectionOnly;
var cost = free ? 0m : shippingCost;
var cacheKey = $"{postage}_{cost:F2}";
if (_policyCache.TryGetValue(cacheKey, out var cached)) return cached;
var serviceCode = ToShippingServiceCode(postage);
var policyName = $"ELT_{postage}_{cost:F2}".Replace(" ", "");
object shippingServiceObj;
if (postage == PostageOption.CollectionOnly)
{
shippingServiceObj = new
{
shippingServiceCode = serviceCode,
shippingCost = new { value = "0.00", currency = "GBP" },
freeShipping = false,
buyerResponsibleForShipping = true,
sortOrder = 1
};
}
else
{
shippingServiceObj = new
{
shippingCarrierCode = "RoyalMail",
shippingServiceCode = serviceCode,
shippingCost = new { value = cost.ToString("F2"), currency = "GBP" },
freeShipping = free,
sortOrder = 1
};
}
var body = new
{
name = policyName,
marketplaceId = "EBAY_GB",
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
handlingTime = new { value = 2, unit = "DAY" },
shippingOptions = new[]
{
new
{
optionType = "DOMESTIC",
costType = postage == PostageOption.CollectionOnly ? "NOT_SPECIFIED" : "FLAT_RATE",
shippingServices = new[] { shippingServiceObj }
}
}
};
var json = JsonConvert.SerializeObject(body,
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/account/v1/fulfillment_policy", token);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
var res = await _http.SendAsync(req);
var resJson = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException(
$"Could not create fulfillment policy ({(int)res.StatusCode}): {resJson}");
var policyId = JObject.Parse(resJson)["fulfillmentPolicyId"]?.ToString()
?? throw new InvalidOperationException("No fulfillmentPolicyId in response.");
_policyCache[cacheKey] = policyId;
SavePolicyCacheToDisk();
return policyId;
}
// ---- Setup: policies + location ----
/// <summary>
/// Fetches fulfillment, payment and return policy IDs from the seller's eBay account,
/// and ensures at least one merchant location exists (creating "home" from the seller's
/// postcode if needed). Results are cached for the session.
/// </summary>
private async Task EnsurePoliciesAndLocationAsync(string token, string postcode)
{
var baseUrl = _auth.BaseUrl;
if (_paymentPolicyId == null)
{
using var req = MakeRequest(HttpMethod.Get,
$"{baseUrl}/sell/account/v1/payment_policy?marketplace_id=EBAY_GB", token);
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException(
$"Could not fetch payment policies ({(int)res.StatusCode}): {json}");
var arr = JObject.Parse(json)["paymentPolicies"] as JArray;
_paymentPolicyId = arr?.Count > 0
? arr[0]["paymentPolicyId"]?.ToString()
: null;
if (_paymentPolicyId == null)
throw new InvalidOperationException(
"No payment policy found on your eBay account.\n\n" +
"Please set one up in My eBay → Account → Business policies, then try again.");
}
if (_returnPolicyId == null)
{
using var req = MakeRequest(HttpMethod.Get,
$"{baseUrl}/sell/account/v1/return_policy?marketplace_id=EBAY_GB", token);
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException(
$"Could not fetch return policies ({(int)res.StatusCode}): {json}");
var arr = JObject.Parse(json)["returnPolicies"] as JArray;
_returnPolicyId = arr?.Count > 0
? arr[0]["returnPolicyId"]?.ToString()
: null;
if (_returnPolicyId == null)
throw new InvalidOperationException(
"No return policy found on your eBay account.\n\n" +
"Please set one up in My eBay → Account → Business policies, then try again.");
}
if (_merchantLocationKey == null)
{
using var req = MakeRequest(HttpMethod.Get,
$"{baseUrl}/sell/inventory/v1/location", token);
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (res.IsSuccessStatusCode)
{
var arr = JObject.Parse(json)["locations"] as JArray;
_merchantLocationKey = arr?.Count > 0
? arr[0]["merchantLocationKey"]?.ToString()
: null;
}
// No existing locations — create one from the seller's postcode
if (_merchantLocationKey == null)
{
await CreateMerchantLocationAsync(token, postcode);
_merchantLocationKey = "home";
}
}
}
private async Task CreateMerchantLocationAsync(string token, string postcode)
{
if (string.IsNullOrWhiteSpace(postcode))
postcode = "N/A"; // eBay allows this when postcode is genuinely unknown
var body = new
{
location = new
{
address = new { postalCode = postcode, country = "GB" }
},
locationTypes = new[] { "WAREHOUSE" },
name = "Home",
merchantLocationStatus = "ENABLED"
};
using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/inventory/v1/location/home", token);
req.Content = new StringContent(
JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
var res = await _http.SendAsync(req);
var json = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException(
$"Could not create merchant location ({(int)res.StatusCode}): {json}");
}
// ---- Inventory item ----
private async Task CreateInventoryItemAsync(ListingDraft draft, List<string> imageUrls, string token) 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 see photos" : null,
description = draft.Description, description = draft.Description,
title = draft.Title, 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 = draft.Aspects.Count > 0
? draft.Aspects.ToDictionary(kv => kv.Key, kv => new[] { kv.Value })
: (object?)null
} }
}; };
@@ -85,40 +329,41 @@ public class EbayListingService
}); });
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(json, 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,
marketplaceId = "EBAY_GB", marketplaceId = "EBAY_GB",
format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE", format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE",
availableQuantity = draft.Quantity, availableQuantity = draft.Quantity,
categoryId = draft.CategoryId, categoryId = draft.CategoryId,
listingDescription = draft.Description, listingDescription = draft.Description,
listingPolicies, listingPolicies = new
{
fulfillmentPolicyId = _fulfillmentPolicyId,
paymentPolicyId = _paymentPolicyId,
returnPolicyId = _returnPolicyId
},
pricingSummary = new pricingSummary = new
{ {
price = new { value = draft.Price.ToString("F2"), currency = "GBP" } price = new { value = draft.Price.ToString("F2"), currency = "GBP" }
}, },
merchantLocationKey = "home", merchantLocationKey = _merchantLocationKey,
tax = new { vatPercentage = 0, applyTax = false } tax = new { vatPercentage = 0, applyTax = false }
}; };
@@ -127,39 +372,46 @@ public class EbayListingService
NullValueHandling = NullValueHandling.Ignore NullValueHandling = NullValueHandling.Ignore
}); });
var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer"; using var req = MakeRequest(HttpMethod.Post,
var response = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json")); $"{_auth.BaseUrl}/sell/inventory/v1/offer", token);
var responseJson = await response.Content.ReadAsStringAsync(); req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Content.Headers.Add("Content-Language", "en-US");
if (!response.IsSuccessStatusCode) var res = await _http.SendAsync(req);
var responseJson = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"Failed to create offer: {responseJson}"); 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>(); var urls = new List<string>();
if (photoPaths.Count == 0) return urls; 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";
@@ -167,7 +419,6 @@ public class EbayListingService
foreach (var path in photoPaths.Take(12)) foreach (var path in photoPaths.Take(12))
{ {
if (!File.Exists(path)) continue; if (!File.Exists(path)) continue;
try try
{ {
var url = await UploadSinglePhotoAsync(path, tradingBase, token); var url = await UploadSinglePhotoAsync(path, tradingBase, token);
@@ -176,7 +427,7 @@ public class EbayListingService
} }
catch catch
{ {
// Skip failed photo uploads, don't abort the whole listing // Skip failed photos; don't abort the whole listing
} }
} }
@@ -186,8 +437,7 @@ public class EbayListingService
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 +447,39 @@ 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 // Use HttpRequestMessage with _photoHttp so we don't create a new socket per photo
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 /// <summary>Creates a pre-authorised request targeting the eBay REST APIs.</summary>
{ private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
["shippingPolicyName"] = "Default",
["paymentPolicyName"] = "Default",
["returnPolicyName"] = "Default",
["shippingCostType"] = "FLAT_RATE",
["shippingOptions"] = new JArray
{
new JObject
{
["optionType"] = "DOMESTIC",
["costType"] = "FLAT_RATE",
["shippingServices"] = new JArray
{
new JObject
{
["shippingServiceCode"] = serviceCode,
["shippingCost"] = new JObject
{
["value"] = costValue,
["currency"] = "GBP"
}
}
}
}
}
};
}
private HttpClient BuildClient(string token)
{ {
var http = new HttpClient(); var req = new HttpRequestMessage(method, url);
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB"); req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
return http; return req;
} }
} }

View File

@@ -0,0 +1,83 @@
using System.Text.RegularExpressions;
using EbayListingTool.Models;
namespace EbayListingTool.Services;
public record PriceSuggestion(decimal Price, string Source, string Label);
/// <summary>
/// Layered price suggestion: eBay live data → own listing history → AI estimate.
/// Returns the first source that produces a result, labelled so the UI can show
/// where the suggestion came from.
/// </summary>
public class PriceLookupService
{
private readonly EbayPriceResearchService _ebay;
private readonly SavedListingsService _history;
private readonly AiAssistantService _ai;
private static readonly Regex PriceRegex =
new(@"PRICE:\s*(\d+\.?\d*)", RegexOptions.IgnoreCase);
public PriceLookupService(
EbayPriceResearchService ebay,
SavedListingsService history,
AiAssistantService ai)
{
_ebay = ebay;
_history = history;
_ai = ai;
}
public async Task<PriceSuggestion?> GetSuggestionAsync(SavedListing listing)
{
// 1. eBay live listings
try
{
var result = await _ebay.GetLivePricesAsync(listing.Title);
if (result.HasSuggestion)
return new PriceSuggestion(
result.Suggested,
"ebay",
$"eBay suggests \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;
}
}

View File

@@ -1,4 +1,4 @@
using EbayListingTool.Models; using EbayListingTool.Models;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace EbayListingTool.Services; namespace EbayListingTool.Services;
@@ -35,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();

View File

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

View File

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

View File

@@ -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>
</mah:MetroWindow.Resources>
<mah:MetroWindow.RightWindowCommands> <!-- Shared style for tab header icon -->
<mah:WindowCommands> <Style x:Key="TabHeaderIcon" TargetType="iconPacks:PackIconMaterial">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,8,0"> <Setter Property="Width" Value="15"/>
<Border CornerRadius="10" Padding="8,3" Margin="0,0,8,0" <Setter Property="Height" Value="15"/>
Background="#22FFFFFF" VerticalAlignment="Center"> <Setter Property="Margin" Value="0,0,7,0"/>
<StackPanel Orientation="Horizontal"> <Setter Property="VerticalAlignment" Value="Center"/>
<Ellipse x:Name="StatusDot" Style="{StaticResource ConnectedDotStyle}" Fill="#777"/> </Style>
<TextBlock x:Name="StatusLabel" Text="eBay: not connected" </mah:MetroWindow.Resources>
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>

View File

@@ -15,32 +15,29 @@ 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()
{ {
InitializeComponent(); InitializeComponent();
var config = App.Configuration; var config = App.Configuration;
_auth = new EbayAuthService(config); _auth = new EbayAuthService(config);
_categoryService = new EbayCategoryService(_auth); _categoryService = new EbayCategoryService(_auth);
_listingService = new EbayListingService(_auth, _categoryService); _listingService = new EbayListingService(_auth, _categoryService);
_aiService = new AiAssistantService(config); _aiService = new AiAssistantService(config);
_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); StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen);
StatusLabel.Text = $"eBay: {_auth.ConnectedUsername}"; StatusBarEbay.Text = $"eBay: {_auth.ConnectedUsername}";
StatusBarDot.Fill = new SolidColorBrush(Colors.LimeGreen);
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); StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
StatusLabel.Text = "eBay: not connected"; StatusBarEbay.Text = "eBay: disconnected";
StatusBarDot.Fill = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0x88));
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)
{
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 SwitchToNewListingTab() private void Exit_Click(object sender, RoutedEventArgs e) => Close();
{
MainTabs.SelectedItem = NewListingTab;
}
public void RefreshSavedListings() // ---- Public interface for child views ----
{
SavedView.RefreshList();
}
// ---- Helpers ----
public void SetStatus(string message) => StatusBar.Text = message; public void SetStatus(string message) => StatusBar.Text = message;
public void SwitchToNewListingTab() => MainTabs.SelectedItem = NewListingTab;
public void RefreshDrafts() => SavedView.RefreshList();
public void RefreshSavedListings() => RefreshDrafts(); // backwards compat for NewListingView
} }

View File

@@ -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 &amp; Price with AI"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<TextBlock x:Name="PhotoCountLabel"
HorizontalAlignment="Center" Margin="0,8,0,0"
FontSize="13" Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
</StackPanel>
<Grid/> <!-- fill remaining space -->
</DockPanel>
</Grid>
<!-- STATE B: Review & Edit -->
<Grid x:Name="StateB" Visibility="Collapsed">
<DockPanel LastChildFill="True">
<!-- Footer bar - pinned to bottom via DockPanel.Dock -->
<Border DockPanel.Dock="Bottom"
Background="{DynamicResource MahApps.Brushes.Gray9}"
BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}"
Padding="16,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" x:Name="StartOverBtn"
Click="StartOver_Click"
Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Cursor="Hand" VerticalAlignment="Center"
AutomationProperties.Name="Start over and discard edits">
<TextBlock FontSize="13">
<Run Text="&#8592; "/>
<Run Text="Start Over" TextDecorations="Underline"/>
</TextBlock>
</Button>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<Button x:Name="SaveDraftBtn"
Click="SaveDraft_Click"
Style="{StaticResource MahApps.Styles.Button.Square}"
Padding="16,8" Margin="0,0,8,0"
AutomationProperties.Name="Save listing as draft">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentSaveOutline"
Width="14" Height="14" Margin="0,0,6,0"
VerticalAlignment="Center"/>
<TextBlock Text="Save as Draft" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button x:Name="PostBtn"
Click="Post_Click"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
Padding="16,8"
AutomationProperties.Name="Post listing to eBay">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="PostIcon"
Kind="CartArrowRight" Width="14" Height="14"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="PostSpinner"
Width="14" Height="14" Margin="0,0,6,0"
Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="Post to eBay" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- Two-column content area -->
<Grid Margin="16,12,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220" MinWidth="160"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- LEFT: Photos panel -->
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top"
Text="PHOTOS" Style="{StaticResource SectionHeader}"
Margin="0,0,0,8"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button x:Name="AddMorePhotosBtn" Click="AddMorePhotos_Click"
Style="{StaticResource MahApps.Styles.Button.Square}"
Padding="8,4" FontSize="13"
AutomationProperties.Name="Add more photos to listing">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Plus" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<TextBlock Text="Add more" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<TextBlock x:Name="BPhotoCount"
Margin="8,0,0,0" VerticalAlignment="Center"
FontSize="13"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto"
Focusable="False">
<WrapPanel x:Name="BPhotosPanel"/>
</ScrollViewer>
</DockPanel>
<!-- RIGHT: Listing fields -->
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto"
Focusable="False">
<StackPanel Margin="0,0,8,16" MaxWidth="600">
<TextBlock Text="LISTING DETAILS"
Style="{StaticResource SectionHeader}"
Margin="0,0,0,12"/>
<!-- Title -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="TitleLabel" Text="Title"
Style="{StaticResource FieldLabel}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="AiTitleBtn" Click="AiTitle_Click"
Style="{StaticResource AiActionButton}"
ToolTip="Improve title with AI"
AutomationProperties.Name="Improve title with AI">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="TitleAiIcon"
Kind="AutoFix" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="TitleSpinner" Width="12" Height="12"
Margin="0,0,4,0" Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="AI" FontSize="12" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<TextBox x:Name="BTitleBox" TextChanged="TitleBox_TextChanged"
MaxLength="80" Margin="0,0,0,2"
AutomationProperties.LabeledBy="{Binding ElementName=TitleLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Style="{StaticResource ProgressTrack}">
<Border x:Name="BTitleBar" Style="{StaticResource ProgressFill}"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Border>
<TextBlock x:Name="BTitleCount" Grid.Column="1"
Text="0 / 80" Style="{StaticResource CharCountLabel}"/>
</Grid>
<!-- Description -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="DescLabel" Text="Description"
Style="{StaticResource FieldLabel}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="AiDescBtn" Click="AiDesc_Click"
Style="{StaticResource AiActionButton}"
ToolTip="Write description with AI"
AutomationProperties.Name="Write description with AI">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="DescAiIcon"
Kind="AutoFix" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="DescSpinner" Width="12" Height="12"
Margin="0,0,4,0" Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="AI" FontSize="12" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<TextBox x:Name="BDescBox" TextChanged="DescBox_TextChanged"
AcceptsReturn="True" TextWrapping="Wrap"
Height="110" VerticalScrollBarVisibility="Auto"
Margin="0,0,0,2"
AutomationProperties.LabeledBy="{Binding ElementName=DescLabel}"/>
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Style="{StaticResource ProgressTrack}">
<Border x:Name="BDescBar" Style="{StaticResource ProgressFill}"
Background="{DynamicResource MahApps.Brushes.Accent}"/>
</Border>
<TextBlock x:Name="BDescCount" Grid.Column="1"
Text="0 / 2000" Style="{StaticResource CharCountLabel}"/>
</Grid>
<!-- Category -->
<TextBlock x:Name="CategoryLabel" Text="Category"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<Grid Margin="0,0,0,2">
<TextBox x:Name="BCategoryBox"
TextChanged="CategoryBox_TextChanged"
KeyDown="CategoryBox_KeyDown"
mah:TextBoxHelper.Watermark="Type to search categories."
AutomationProperties.LabeledBy="{Binding ElementName=CategoryLabel}"/>
<ListBox x:Name="BCategoryList"
Visibility="Collapsed"
SelectionChanged="CategoryList_SelectionChanged"
MaxHeight="160"
VerticalAlignment="Top"
Margin="0,32,0,0"
Panel.ZIndex="10"
Background="{DynamicResource MahApps.Brushes.Gray8}"
BorderBrush="{DynamicResource MahApps.Brushes.Gray6}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.CanContentScroll="True"
AutomationProperties.Name="Category suggestions"/>
</Grid>
<TextBlock x:Name="BCategoryIdLabel"
Text="(no category selected)"
FontSize="12" Margin="0,0,0,12"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<!-- Condition + Format -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock x:Name="ConditionLabel" Text="Condition"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<ComboBox x:Name="BConditionBox"
SelectionChanged="ConditionBox_SelectionChanged"
AutomationProperties.LabeledBy="{Binding ElementName=ConditionLabel}">
<ComboBoxItem Content="New" Tag="New"/>
<ComboBoxItem Content="Open Box" Tag="OpenBox"/>
<ComboBoxItem Content="Refurbished" Tag="Refurbished"/>
<ComboBoxItem Content="Used" Tag="Used" IsSelected="True"/>
<ComboBoxItem Content="For Parts" Tag="ForParts"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock x:Name="FormatLabel" Text="Format"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<ComboBox x:Name="BFormatBox"
AutomationProperties.LabeledBy="{Binding ElementName=FormatLabel}">
<ComboBoxItem Content="Fixed Price" IsSelected="True"/>
<ComboBoxItem Content="Auction"/>
</ComboBox>
</StackPanel>
</Grid>
<!-- Price -->
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="PriceLabel" Text="Price"
Style="{StaticResource FieldLabel}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" x:Name="AiPriceBtn" Click="AiPrice_Click"
Style="{StaticResource AiActionButton}"
ToolTip="Research live eBay price"
AutomationProperties.Name="Research live eBay price">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial x:Name="PriceAiIcon"
Kind="Magnify" Width="12" Height="12"
Margin="0,0,4,0" VerticalAlignment="Center"/>
<mah:ProgressRing x:Name="PriceSpinner" Width="12" Height="12"
Margin="0,0,4,0" Visibility="Collapsed"
IsTabStop="False"/>
<TextBlock Text="Research" FontSize="12" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<mah:NumericUpDown x:Name="BPriceBox" ValueChanged="PriceBox_ValueChanged"
StringFormat="&#x00A3;{0:0.00}"
Minimum="0" Maximum="99999"
Interval="0.50"
Margin="0,0,0,4"
AutomationProperties.LabeledBy="{Binding ElementName=PriceLabel}"/>
<TextBlock x:Name="BPriceHint"
FontSize="12" Margin="0,0,0,4"
Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"/>
<TextBlock x:Name="BFeeLabel"
FontSize="12" Margin="0,0,0,12"
Visibility="Collapsed"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
AutomationProperties.Name="Estimated eBay listing fee"/>
<!-- Postage + Postcode -->
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock x:Name="PostageLabel" Text="Postage"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<ComboBox x:Name="BPostageBox" SelectionChanged="PostageBox_SelectionChanged"
AutomationProperties.LabeledBy="{Binding ElementName=PostageLabel}">
<ComboBoxItem Content="Royal Mail 1st Class" Tag="RoyalMailFirstClass"/>
<ComboBoxItem Content="Royal Mail 2nd Class" Tag="RoyalMailSecondClass" IsSelected="True"/>
<ComboBoxItem Content="Royal Mail Tracked 24" Tag="RoyalMailTracked24"/>
<ComboBoxItem Content="Royal Mail Tracked 48" Tag="RoyalMailTracked48"/>
<ComboBoxItem Content="Collection Only" Tag="CollectionOnly"/>
<ComboBoxItem Content="Free Postage" Tag="FreePostage"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock x:Name="PostcodeLabel" Text="From postcode"
Style="{StaticResource FieldLabel}"
Margin="0,0,0,4"/>
<TextBox x:Name="BPostcodeBox"
AutomationProperties.LabeledBy="{Binding ElementName=PostcodeLabel}"/>
</StackPanel>
</Grid>
</StackPanel>
</ScrollViewer>
</Grid>
</DockPanel>
</Grid>
<!-- STATE C: Success -->
<Grid x:Name="StateC" Visibility="Collapsed">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="480">
<!-- Success banner -->
<Border Background="#1A4CAF50" BorderBrush="#4CAF50" BorderThickness="0,0,0,3"
CornerRadius="8" Padding="24,16" Margin="0,0,0,28">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<iconPacks:PackIconMaterial Kind="CheckCircleOutline" Width="24" Height="24"
Foreground="#4CAF50" VerticalAlignment="Center" Margin="0,0,12,0"
IsTabStop="False"/>
<TextBlock Text="Listed successfully!" FontSize="18" FontWeight="SemiBold"
Foreground="#4CAF50" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- URL -->
<TextBlock Text="Your listing is live at:" FontSize="12"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
HorizontalAlignment="Center" Margin="0,0,0,8"/>
<TextBlock x:Name="BSuccessUrl"
FontSize="13" TextDecorations="Underline"
Foreground="{DynamicResource MahApps.Brushes.Accent}"
HorizontalAlignment="Center" Cursor="Hand" TextWrapping="Wrap"
TextAlignment="Center" Margin="0,0,0,16"
MouseLeftButtonUp="SuccessUrl_Click"
AutomationProperties.Name="Listing URL - click to open"/>
<Button x:Name="CopyUrlBtn" Click="CopyUrl_Click"
Style="{StaticResource MahApps.Styles.Button.Square}"
HorizontalAlignment="Center" Padding="16,8" Margin="0,0,0,36"
AutomationProperties.Name="Copy listing URL to clipboard">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="ContentCopy" Width="13" Height="13"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Copy URL" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!-- List Another -->
<Button Click="ListAnother_Click"
Style="{StaticResource MahApps.Styles.Button.Square.Accent}"
HorizontalAlignment="Center" Padding="24,12" FontSize="14"
AutomationProperties.Name="List another item">
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="Plus" Width="16" Height="16"
Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="List Another Item" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Grid>
</UserControl>

View File

@@ -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 = 240;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
var img = new Image
{
Source = bmp, Width = 192, Height = 192,
Stretch = System.Windows.Media.Stretch.UniformToFill,
Margin = new Thickness(4)
};
img.Clip = new System.Windows.Media.RectangleGeometry(
new Rect(0, 0, 192, 192), 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 = 320;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
var img = new Image
{
Width = 200, Height = 200,
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, 200, 200), 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 = 200, Height = 200, Margin = new Thickness(4),
Cursor = Cursors.SizeAll, AllowDrop = true, Tag = path
};
container.Children.Add(img);
if (coverBadge != null) container.Children.Add(coverBadge);
container.Children.Add(removeBtn);
container.MouseEnter += (s, ev) => removeBtn.Opacity = 1;
container.MouseLeave += (s, ev) => removeBtn.Opacity = 0;
Point dragStart = default;
bool isDragging = false;
container.MouseLeftButtonDown += (s, ev) => dragStart = ev.GetPosition(null);
container.MouseMove += (s, ev) =>
{
if (ev.LeftButton != MouseButtonState.Pressed || isDragging) return;
var pos = ev.GetPosition(null);
if (Math.Abs(pos.X - dragStart.X) > SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(pos.Y - dragStart.Y) > SystemParameters.MinimumVerticalDragDistance)
{
isDragging = true;
DragDrop.DoDragDrop(container, path, DragDropEffects.Move);
isDragging = false;
}
};
container.DragOver += (s, ev) =>
{
if (ev.Data.GetDataPresent(typeof(string)) &&
(string)ev.Data.GetData(typeof(string)) != path)
{ ev.Effects = DragDropEffects.Move; container.Opacity = 0.45; }
else ev.Effects = DragDropEffects.None;
ev.Handled = true;
};
container.DragLeave += (s, ev) => container.Opacity = 1.0;
container.Drop += (s, ev) =>
{
container.Opacity = 1.0;
if (!ev.Data.GetDataPresent(typeof(string))) return;
var src = (string)ev.Data.GetData(typeof(string));
var tgt = (string)container.Tag;
if (src == tgt) return;
var si = _draft.PhotoPaths.IndexOf(src);
var ti = _draft.PhotoPaths.IndexOf(tgt);
if (si < 0 || ti < 0) return;
_draft.PhotoPaths.RemoveAt(si);
_draft.PhotoPaths.Insert(ti, src);
RebuildBPhotoThumbnails();
ev.Handled = true;
};
BPhotosPanel.Children.Add(container);
}
catch { }
}
private void AddMorePhotos_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Add more photos",
Filter = "Images|*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp|All files|*.*",
Multiselect = true
};
if (dlg.ShowDialog() == true)
{
foreach (var path in dlg.FileNames)
{
if (_draft.PhotoPaths.Count >= MaxPhotos) break;
if (!_draft.PhotoPaths.Contains(path)) _draft.PhotoPaths.Add(path);
}
RebuildBPhotoThumbnails();
}
}
// ---- Footer actions ----
private void StartOver_Click(object sender, RoutedEventArgs e)
{
var isDirty = !string.IsNullOrWhiteSpace(BTitleBox.Text) ||
!string.IsNullOrWhiteSpace(BDescBox.Text);
if (isDirty)
{
var result = MessageBox.Show("Start over? Any edits will be lost.",
"Start Over", MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (result != MessageBoxResult.OK) return;
}
ResetToStateA();
}
public void ResetToStateA()
{
_photoPaths.Clear();
_draft = new ListingDraft { Postcode = _defaultPostcode };
_lastAnalysis = null;
UpdateThumbStrip();
UpdateAnalyseButton();
if (BPhotosPanel != null) BPhotosPanel.Children.Clear();
if (BTitleBox != null) BTitleBox.Text = "";
if (BDescBox != null) BDescBox.Text = "";
if (BCategoryBox != null) { BCategoryBox.Text = ""; BCategoryList.Visibility = Visibility.Collapsed; }
if (BCategoryIdLabel != null) BCategoryIdLabel.Text = "(no category selected)";
if (BPriceBox != null) BPriceBox.Value = 0;
if (BPriceHint != null) BPriceHint.Visibility = Visibility.Collapsed;
if (BConditionBox != null) BConditionBox.SelectedIndex = 3;
if (BFormatBox != null) BFormatBox.SelectedIndex = 0;
if (BPostcodeBox != null) BPostcodeBox.Text = _defaultPostcode;
SetState(ListingState.DropZone);
}
private void SaveDraft_Click(object sender, RoutedEventArgs e)
{
if (_savedService == null) return;
if (!ValidateDraft()) return;
CollectDraftFromFields();
try
{
_savedService.Save(
_draft.Title, _draft.Description, _draft.Price,
_draft.CategoryName, "",
_draft.PhotoPaths,
_draft.CategoryId, _draft.Condition, _draft.Format,
BPostcodeBox.Text);
GetWindow()?.RefreshSavedListings();
GetWindow()?.SetStatus($"Draft saved: {_draft.Title}");
ResetToStateA();
}
catch (Exception ex) { ShowError("Save Failed", ex.Message); }
}
private async void Post_Click(object sender, RoutedEventArgs e)
{
if (_listingService == null) return;
if (!ValidateDraft()) return;
CollectDraftFromFields();
SetPostBusy(true);
try
{
var url = await _listingService.PostListingAsync(_draft);
_draft.EbayListingUrl = url;
// Persist a record of the posting
if (_savedService != null)
{
try
{
_savedService.Save(
_draft.Title, _draft.Description, _draft.Price,
_draft.CategoryName, $"Posted: {url}",
_draft.PhotoPaths,
_draft.CategoryId, _draft.Condition, _draft.Format,
_draft.Postcode);
GetWindow()?.RefreshSavedListings();
}
catch { /* non-critical — posting succeeded, history save is best-effort */ }
}
BSuccessUrl.Text = url;
SetState(ListingState.Success);
GetWindow()?.SetStatus($"Listed: {_draft.Title}");
}
catch (Exception ex)
{
// Log full stack trace to help diagnose crashes
try
{
var logPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"EbayListingTool", "crash_log.txt");
var msg = $"{DateTime.Now:HH:mm:ss} [Post_Click] {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n";
if (ex.InnerException != null)
msg += $" Inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}\n";
System.IO.File.AppendAllText(logPath, msg + "\n");
}
catch { }
ShowError("Post Failed", ex.Message);
}
finally { SetPostBusy(false); }
}
private void CollectDraftFromFields()
{
_draft.Title = BTitleBox.Text.Trim();
_draft.Description = BDescBox.Text.Trim();
_draft.Price = (decimal)(BPriceBox.Value ?? 0);
_draft.Condition = GetSelectedCondition();
_draft.Format = BFormatBox.SelectedIndex == 0 ? ListingFormat.FixedPrice : ListingFormat.Auction;
_draft.Postcode = BPostcodeBox.Text;
_draft.Quantity = 1;
}
private bool ValidateDraft()
{
if (string.IsNullOrWhiteSpace(BTitleBox?.Text))
{ ShowError("Validation", "Please enter a title."); return false; }
if (BTitleBox.Text.Length > 80)
{ ShowError("Validation", "Title must be 80 characters or fewer."); return false; }
if (string.IsNullOrEmpty(_draft.CategoryId))
{ ShowError("Validation", "Please select a category."); return false; }
if ((BPriceBox?.Value ?? 0) <= 0)
{ ShowError("Validation", "Please enter a price greater than zero."); return false; }
return true;
}
private void SetPostBusy(bool busy)
{
PostBtn.IsEnabled = !busy;
PostSpinner.Visibility = busy ? Visibility.Visible : Visibility.Collapsed;
PostIcon.Visibility = busy ? Visibility.Collapsed : Visibility.Visible;
IsEnabled = !busy;
}
private void ShowError(string title, string msg)
=> MessageBox.Show(msg, title, MessageBoxButton.OK, MessageBoxImage.Warning);
// ---- State C handlers ----
private void SuccessUrl_Click(object sender, MouseButtonEventArgs e)
{
var url = BSuccessUrl.Text;
if (!string.IsNullOrEmpty(url))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url)
{ UseShellExecute = true });
}
private void CopyUrl_Click(object sender, RoutedEventArgs e)
=> System.Windows.Clipboard.SetText(BSuccessUrl.Text);
private void ListAnother_Click(object sender, RoutedEventArgs e)
=> ResetToStateA();
private static bool IsImageFile(string path)
{
var ext = System.IO.Path.GetExtension(path).ToLowerInvariant();
return ext is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp";
}
private MainWindow? GetWindow() => Window.GetWindow(this) as MainWindow;
}

View File

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

View File

@@ -4,6 +4,7 @@ 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 System.Windows.Threading;
using EbayListingTool.Helpers;
using EbayListingTool.Models; using EbayListingTool.Models;
using EbayListingTool.Services; using EbayListingTool.Services;
using Microsoft.Win32; using Microsoft.Win32;
@@ -285,9 +286,65 @@ public partial class PhotoAnalysisView : UserControl
private void ShowResults(PhotoAnalysisResult r) private void ShowResults(PhotoAnalysisResult r)
{ {
IdlePanel.Visibility = Visibility.Collapsed; IdlePanel.Visibility = Visibility.Collapsed;
LoadingPanel.Visibility = Visibility.Collapsed; LoadingPanel.Visibility = Visibility.Collapsed;
ResultsPanel.Visibility = Visibility.Visible; ResultsPanel.Visibility = Visibility.Collapsed; // hidden behind card preview
CardPreviewPanel.Visibility = Visibility.Visible;
CardChangePanel.Visibility = Visibility.Collapsed;
ChangeChevron.Kind = MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronDown;
// --- Populate card preview ---
CardItemName.Text = r.ItemName;
CardCondition.Text = r.ConditionNotes;
CardCategory.Text = r.CategoryKeyword;
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(r.PriceSuggested);
CardPriceDigit.Text = r.PriceSuggested > 0 ? $"£{r.PriceSuggested:F2}" : "";
CardLivePriceNote.Visibility = Visibility.Collapsed;
// Cover photo
if (_currentImagePaths.Count > 0)
{
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(_currentImagePaths[0], UriKind.Absolute);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = 220;
bmp.EndInit();
bmp.Freeze();
CardPhoto.Source = bmp;
}
catch { CardPhoto.Source = null; }
}
// Photo dots — one dot per photo, filled accent for first
CardPhotoDots.Children.Clear();
for (int i = 0; i < _currentImagePaths.Count; i++)
{
CardPhotoDots.Children.Add(new System.Windows.Shapes.Ellipse
{
Width = 7, Height = 7,
Margin = new Thickness(2, 0, 2, 0),
Fill = i == 0
? (Brush)FindResource("MahApps.Brushes.Accent")
: (Brush)FindResource("MahApps.Brushes.Gray7")
});
}
CardPhotoDots.Visibility = _currentImagePaths.Count > 1
? Visibility.Visible : Visibility.Collapsed;
// Price slider — centre on suggested, range ±60% clamped to sensible bounds
var suggested = (double)(r.PriceSuggested > 0 ? r.PriceSuggested : 10m);
PriceSliderCard.Minimum = Math.Max(0.50, Math.Round(suggested * 0.4 * 2) / 2);
PriceSliderCard.Maximum = Math.Round(suggested * 1.8 * 2) / 2;
PriceSliderCard.Value = Math.Round(suggested * 2) / 2; // snap to 50p
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(r.PriceSuggested);
SliderDigitLabel.Text = $"£{r.PriceSuggested:F2}";
// Card title box
CardTitleBox.Text = r.Title;
UpdateCardTitleBar(r.Title.Length);
// Item identification // Item identification
ItemNameText.Text = r.ItemName; ItemNameText.Text = r.ItemName;
@@ -351,10 +408,6 @@ public partial class PhotoAnalysisView : UserControl
// Reset live price row until lookup completes // Reset live price row until lookup completes
LivePriceRow.Visibility = Visibility.Collapsed; LivePriceRow.Visibility = Visibility.Collapsed;
// Animate results in
var sb = (Storyboard)FindResource("ResultsReveal");
sb.Begin(this);
} }
private async Task UpdateLivePricesAsync(string query) private async Task UpdateLivePricesAsync(string query)
@@ -392,9 +445,23 @@ public partial class PhotoAnalysisView : UserControl
// Update suggested price to 40th percentile (competitive but not cheapest) // Update suggested price to 40th percentile (competitive but not cheapest)
var suggested = live.Suggested; var suggested = live.Suggested;
PriceSuggestedText.Text = $"£{suggested:F2}"; PriceSuggestedText.Text = $"£{suggested:F2}";
PriceOverride.Value = (double)Math.Round(suggested, 2); // Issue 6: avoid decimal→double drift PriceOverride.Value = (double)Math.Round(suggested, 2);
if (_lastResult != null) _lastResult.PriceSuggested = suggested; if (_lastResult != null) _lastResult.PriceSuggested = suggested;
// Update card preview price
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(suggested);
CardPriceDigit.Text = $"£{suggested:F2}";
var snapped = Math.Round((double)suggested * 2) / 2;
if (snapped >= PriceSliderCard.Minimum && snapped <= PriceSliderCard.Maximum)
PriceSliderCard.Value = snapped;
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(suggested);
SliderDigitLabel.Text = $"£{suggested:F2}";
// Show note on card
var noteText = $"eBay: {live.Count} similar listing{(live.Count == 1 ? "" : "s")}, range £{live.Min:F2}–£{live.Max:F2}";
CardLivePriceNote.Text = noteText;
CardLivePriceNote.Visibility = Visibility.Visible;
// Update status label // Update status label
LivePriceSpinner.Visibility = Visibility.Collapsed; LivePriceSpinner.Visibility = Visibility.Collapsed;
LivePriceStatus.Text = LivePriceStatus.Text =
@@ -666,6 +733,63 @@ public partial class PhotoAnalysisView : UserControl
Clipboard.SetText(DescriptionBox.Text); Clipboard.SetText(DescriptionBox.Text);
} }
// ---- Card preview handlers ----
private void LooksGood_Click(object sender, RoutedEventArgs e)
=> SaveListing_Click(sender, e);
private void ChangeSomething_Click(object sender, RoutedEventArgs e)
{
var expanding = CardChangePanel.Visibility != Visibility.Visible;
CardChangePanel.Visibility = expanding ? Visibility.Visible : Visibility.Collapsed;
ChangeChevron.Kind = expanding
? MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronUp
: MahApps.Metro.IconPacks.PackIconMaterialKind.ChevronDown;
}
private void PriceSliderCard_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
if (!IsLoaded) return;
var price = (decimal)e.NewValue;
SliderVerbalLabel.Text = NumberWords.ToVerbalPrice(price);
SliderDigitLabel.Text = $"£{price:F2}";
// Keep card price display in sync
CardPriceVerbal.Text = NumberWords.ToVerbalPrice(price);
CardPriceDigit.Text = $"£{price:F2}";
// Keep hidden ResultsPanel in sync so SaveListing_Click gets the right value
PriceOverride.Value = e.NewValue;
if (_lastResult != null) _lastResult.PriceSuggested = price;
}
private void CardTitleBox_TextChanged(object sender, TextChangedEventArgs e)
{
var len = CardTitleBox.Text.Length;
UpdateCardTitleBar(len);
// Keep hidden ResultsPanel in sync
TitleBox.Text = CardTitleBox.Text;
}
private void UpdateCardTitleBar(int len)
{
if (!IsLoaded) return;
var trackWidth = CardTitleBar.Parent is Grid g ? g.ActualWidth : 0;
if (trackWidth <= 0) return;
CardTitleBar.Width = trackWidth * (len / 80.0);
CardTitleBar.Background = len > 75
? System.Windows.Media.Brushes.OrangeRed
: (System.Windows.Media.Brush)FindResource("MahApps.Brushes.Accent");
}
private void SaveWithChanges_Click(object sender, RoutedEventArgs e)
{
if (_lastResult != null)
{
_lastResult.Title = CardTitleBox.Text.Trim();
TitleBox.Text = _lastResult.Title;
}
SaveListing_Click(sender, e);
}
// ---- Loading step cycling ---- // ---- Loading step cycling ----
private void LoadingTimer_Tick(object? sender, EventArgs e) private void LoadingTimer_Tick(object? sender, EventArgs e)

View File

@@ -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">
<TextBlock x:Name="DetailPrice" <!-- Normal price badge + Revalue button -->
FontSize="16" FontWeight="Bold" Foreground="White"/> <StackPanel x:Name="PriceDisplayRow" Orientation="Horizontal">
</Border> <Border Background="{DynamicResource MahApps.Brushes.Accent}"
CornerRadius="6" Padding="10,4">
<TextBlock x:Name="DetailPrice"
FontSize="16" FontWeight="Bold" Foreground="White"/>
</Border>
<Button x:Name="RevalueBtn" Click="RevalueBtn_Click"
Height="28" Padding="8,0" Margin="6,0,0,0"
Style="{DynamicResource MahApps.Styles.Button.Square}"
ToolTip="Quick-change the price"
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 &amp; Accessories"
AutomationProperties.LabeledBy="{Binding ElementName=EditCategoryLabel}"/>
</StackPanel>
</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> </Grid>
<!-- Draft posted toast -->
<Border x:Name="DraftPostedToast"
Grid.Column="2"
Visibility="Collapsed"
VerticalAlignment="Bottom"
Margin="12" Padding="14,10"
CornerRadius="6"
Background="{DynamicResource MahApps.Brushes.Gray8}"
BorderThickness="0,0,0,3"
BorderBrush="{DynamicResource MahApps.Brushes.Accent}"
Panel.ZIndex="10">
<Border.RenderTransform>
<TranslateTransform x:Name="ToastTranslate" Y="60"/>
</Border.RenderTransform>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<iconPacks:PackIconMaterial Kind="CheckCircleOutline"
Width="16" Height="16" Margin="0,0,10,0"
Foreground="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center"
IsTabStop="False"/>
<TextBlock x:Name="ToastUrlText" Grid.Column="1"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource MahApps.Brushes.Gray1}" FontSize="12"/>
<Button Grid.Column="2" Content="x" Width="20" Height="20"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Click="DismissToast_Click" Margin="8,0,0,0"
AutomationProperties.Name="Dismiss notification"/>
</Grid>
</Border>
</Grid> </Grid>
</UserControl> </UserControl>

View File

@@ -4,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);
}
} }

View File

@@ -204,6 +204,55 @@
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
<!-- Item Specifics (Aspects) panel — revealed after category is selected -->
<Border x:Name="AspectsPanel" Visibility="Collapsed"
CornerRadius="4" Margin="0,10,0,0" Padding="12,10"
BorderBrush="{DynamicResource MahApps.Brushes.Gray7}" BorderThickness="1">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<mah:ProgressRing x:Name="AspectsSpinner" Width="14" Height="14"
Margin="0,0,6,0" VerticalAlignment="Center"
Visibility="Collapsed"/>
<TextBlock Text="Item Specifics" FontWeight="SemiBold" FontSize="12"
VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray2}"/>
<TextBlock x:Name="AspectsRequiredNote"
Text=" (required fields marked *)"
FontSize="11" VerticalAlignment="Center"
Foreground="{DynamicResource MahApps.Brushes.Gray5}"
Visibility="Collapsed"/>
</StackPanel>
<Button Grid.Column="1" x:Name="AiAspectsBtn"
Style="{StaticResource AiButton}"
Height="26" Padding="10,0" FontSize="11"
Click="AiAspects_Click"
ToolTip="Let AI suggest values from your title and description">
<StackPanel Orientation="Horizontal">
<mah:ProgressRing x:Name="AspectsAiSpinner" Width="11" Height="11"
Margin="0,0,4,0" Foreground="White"
Visibility="Collapsed"/>
<iconPacks:PackIconMaterial x:Name="AspectsAiIcon" Kind="AutoFix"
Width="11" Height="11" Margin="0,0,4,0"
VerticalAlignment="Center"/>
<TextBlock Text="AI Suggest" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
<ItemsControl x:Name="AspectsItemsControl">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</Border>
<!-- Condition + Format --> <!-- Condition + Format -->
<Grid Margin="0,10,0,0"> <Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
@@ -311,6 +360,8 @@
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/> <ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Price with inline AI button --> <!-- Price with inline AI button -->
@@ -352,7 +403,7 @@
<StackPanel Grid.Column="4"> <StackPanel Grid.Column="4">
<TextBlock Style="{StaticResource FieldLabel}" Text="Postage"/> <TextBlock Style="{StaticResource FieldLabel}" Text="Postage"/>
<ComboBox x:Name="PostageBox"> <ComboBox x:Name="PostageBox" SelectionChanged="PostageBox_SelectionChanged">
<ComboBoxItem Content="Royal Mail 1st Class (~£1.55)" IsSelected="True"/> <ComboBoxItem Content="Royal Mail 1st Class (~£1.55)" IsSelected="True"/>
<ComboBoxItem Content="Royal Mail 2nd Class (~£1.20)"/> <ComboBoxItem Content="Royal Mail 2nd Class (~£1.20)"/>
<ComboBoxItem Content="Royal Mail Tracked 24 (~£2.90)"/> <ComboBoxItem Content="Royal Mail Tracked 24 (~£2.90)"/>
@@ -361,6 +412,14 @@
<ComboBoxItem Content="Collection Only"/> <ComboBoxItem Content="Collection Only"/>
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="6">
<TextBlock Style="{StaticResource FieldLabel}" Text="Shipping Cost (£)"/>
<mah:NumericUpDown x:Name="ShippingCostBox"
Minimum="0" Maximum="99" StringFormat="F2"
Interval="0.50" Value="0"
Width="110" HorizontalAlignment="Left"/>
</StackPanel>
</Grid> </Grid>
<!-- Postcode — narrower input, left-aligned --> <!-- Postcode — narrower input, left-aligned -->
@@ -439,7 +498,7 @@
Foreground="#4CAF50"/> Foreground="#4CAF50"/>
<TextBlock Text="Posted! " FontWeight="Bold" VerticalAlignment="Center" <TextBlock Text="Posted! " FontWeight="Bold" VerticalAlignment="Center"
Foreground="#4CAF50"/> Foreground="#4CAF50"/>
<TextBlock x:Name="ListingUrlText" Foreground="#1565C0" <TextBlock x:Name="ListingUrlText" Foreground="{DynamicResource MahApps.Brushes.Accent}"
VerticalAlignment="Center" VerticalAlignment="Center"
Cursor="Hand" TextDecorations="Underline" Cursor="Hand" TextDecorations="Underline"
MouseLeftButtonUp="ListingUrl_Click"/> MouseLeftButtonUp="ListingUrl_Click"/>

View File

@@ -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;
}

View File

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