Compare commits

...

7 Commits

Author SHA1 Message Date
Basilosaurusrex
1c5f68967c fix v3 2026-03-02 12:51:20 +01:00
Basilosaurusrex
6a33ac6cff login fix v2 2026-03-02 12:47:57 +01:00
Basilosaurusrex
a29086173f login fix 2026-03-02 11:59:29 +01:00
KNSONWS
a1201d572e feat: Integrate account_metrics collection with monthly refresh calendar
- Add account_metrics collection schema script
- Implement accountMetricsService with upsertAccountMetric, fetchAccountMetricsForMonth, calculateSalesBucket
- Extend accountsService with getLastSuccessfulAccountMetric
- Update AccountsPage to track daily metrics and display in calendar
- Calculate sales difference from last successful refresh
- Display refresh status and sales buckets in monthly calendar view
- Remove account_refresh_events dependency (use account_metrics only)
2026-01-27 20:36:47 +01:00
75aef1941e Update: Neue Seiten und Komponenten hinzugefügt 2026-01-26 23:38:07 +01:00
d0066d3974 Merge remote changes and update project files 2026-01-26 06:48:58 +01:00
636ca1341c Enhance eBay extension logging and account management features
- Added detailed logging for various actions in the background script and content script to improve debugging capabilities.
- Updated the account management flow to include the last updated timestamp and sales data.
- Refined the parsing logic to ensure accurate extraction of seller statistics from eBay profiles.
- Improved error handling in the parsing process to provide more informative responses in case of failures.
2026-01-21 23:01:09 +01:00
43 changed files with 6718 additions and 666 deletions

View File

@@ -1,60 +0,0 @@
{"location":"productsService.js:116","message":"scanProductsForAccount: market derived","data":{"accountId":"696ccb2400395714987c","market":"DE","account_platform_market":"DE","account_url":"https://www.ebay.de/sch/i.html?item=397047173300&rt=nc&_trksid=p4429486.m3561.l161211&_ssn=miceusi"},"timestamp":1768741151411,"sessionId":"debug-session","runId":"run1","hypothesisId":"C"}
{"location":"productsService.js:127","message":"scanProductsForAccount: currency derived","data":{"market":"DE","currency":"EUR","productsCollectionId":"products","databaseId":"eship-db","platformWillBeSetTo":"eBay"},"timestamp":1768741151411,"sessionId":"debug-session","runId":"post-fix","hypothesisId":"E"}
{"location":"productsService.js:190","message":"scanProductsForAccount: payload before createDocument","data":{"platformProductId":"stub_1243_1","product_platform":"ebay","product_platform_type":"string","product_platform_length":4,"product_platform_JSON":"\"ebay\"","fullPayload":"{\"product_account_id\":\"696ccb2400395714987c\",\"product_platform\":\"ebay\",\"product_platform_market\":\"DE\",\"product_currency\":\"EUR\",\"product_platform_product_id\":\"stub_1243_1\",\"product_title\":\"Scanned Item 1\",\"product_price\":58.5,\"product_url\":\"https://www.ebay.de/itm/fake-696ccb24-1\"}","payloadKeys":["product_account_id","product_platform","product_platform_market","product_currency","product_platform_product_id","product_title","product_price","product_url"]},"timestamp":1768741151507,"sessionId":"debug-session","runId":"run2","hypothesisId":"D"}
{"location":"productsService.js:185","message":"scanProductsForAccount: createDocument success","data":{"platformProductId":"stub_1243_1","created":0},"timestamp":1768741151644,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"productsService.js:190","message":"scanProductsForAccount: payload before createDocument","data":{"platformProductId":"stub_1243_2","product_platform":"ebay","product_platform_type":"string","product_platform_length":4,"product_platform_JSON":"\"ebay\"","fullPayload":"{\"product_account_id\":\"696ccb2400395714987c\",\"product_platform\":\"ebay\",\"product_platform_market\":\"DE\",\"product_currency\":\"EUR\",\"product_platform_product_id\":\"stub_1243_2\",\"product_title\":\"Scanned Item 2\",\"product_price\":64,\"product_url\":\"https://www.ebay.de/itm/fake-696ccb24-2\"}","payloadKeys":["product_account_id","product_platform","product_platform_market","product_currency","product_platform_product_id","product_title","product_price","product_url"]},"timestamp":1768741151644,"sessionId":"debug-session","runId":"run2","hypothesisId":"D"}
{"location":"productsService.js:185","message":"scanProductsForAccount: createDocument success","data":{"platformProductId":"stub_1243_2","created":1},"timestamp":1768741151700,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"productsService.js:190","message":"scanProductsForAccount: payload before createDocument","data":{"platformProductId":"stub_1243_3","product_platform":"ebay","product_platform_type":"string","product_platform_length":4,"product_platform_JSON":"\"ebay\"","fullPayload":"{\"product_account_id\":\"696ccb2400395714987c\",\"product_platform\":\"ebay\",\"product_platform_market\":\"DE\",\"product_currency\":\"EUR\",\"product_platform_product_id\":\"stub_1243_3\",\"product_title\":\"Scanned Item 3\",\"product_price\":69.5,\"product_url\":\"https://www.ebay.de/itm/fake-696ccb24-3\"}","payloadKeys":["product_account_id","product_platform","product_platform_market","product_currency","product_platform_product_id","product_title","product_price","product_url"]},"timestamp":1768741151701,"sessionId":"debug-session","runId":"run2","hypothesisId":"D"}
{"location":"productsService.js:185","message":"scanProductsForAccount: createDocument success","data":{"platformProductId":"stub_1243_3","created":2},"timestamp":1768741151751,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"productsService.js:190","message":"scanProductsForAccount: payload before createDocument","data":{"platformProductId":"stub_1243_4","product_platform":"ebay","product_platform_type":"string","product_platform_length":4,"product_platform_JSON":"\"ebay\"","fullPayload":"{\"product_account_id\":\"696ccb2400395714987c\",\"product_platform\":\"ebay\",\"product_platform_market\":\"DE\",\"product_currency\":\"EUR\",\"product_platform_product_id\":\"stub_1243_4\",\"product_title\":\"Scanned Item 4\",\"product_price\":75,\"product_url\":\"https://www.ebay.de/itm/fake-696ccb24-4\"}","payloadKeys":["product_account_id","product_platform","product_platform_market","product_currency","product_platform_product_id","product_title","product_price","product_url"]},"timestamp":1768741151752,"sessionId":"debug-session","runId":"run2","hypothesisId":"D"}
{"location":"productsService.js:185","message":"scanProductsForAccount: createDocument success","data":{"platformProductId":"stub_1243_4","created":3},"timestamp":1768741151800,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"productsService.js:190","message":"scanProductsForAccount: payload before createDocument","data":{"platformProductId":"stub_1243_5","product_platform":"ebay","product_platform_type":"string","product_platform_length":4,"product_platform_JSON":"\"ebay\"","fullPayload":"{\"product_account_id\":\"696ccb2400395714987c\",\"product_platform\":\"ebay\",\"product_platform_market\":\"DE\",\"product_currency\":\"EUR\",\"product_platform_product_id\":\"stub_1243_5\",\"product_title\":\"Scanned Item 5\",\"product_price\":80.5,\"product_url\":\"https://www.ebay.de/itm/fake-696ccb24-5\"}","payloadKeys":["product_account_id","product_platform","product_platform_market","product_currency","product_platform_product_id","product_title","product_price","product_url"]},"timestamp":1768741151800,"sessionId":"debug-session","runId":"run2","hypothesisId":"D"}
{"location":"productsService.js:185","message":"scanProductsForAccount: createDocument success","data":{"platformProductId":"stub_1243_5","created":4},"timestamp":1768741151844,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"ebayParserService.js:292","message":"parseEbayAccount: route decision","data":{"extAvailable":true,"url":"https://www.ebay.de/str/goldbloom25?_trksid=p4429486.m3561.l161211"},"timestamp":1768741538650,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741538651,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:158","message":"parseViaExtension: chrome.runtime.sendMessage error","data":{"error":"Could not establish connection. Receiving end does not exist.","extensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741538659,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:299","message":"parseEbayAccount: extension error, using stub","data":{"error":"Could not establish connection. Receiving end does not exist."},"timestamp":1768741538659,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:304","message":"parseEbayAccount: stub result","data":{"itemsSold":null},"timestamp":1768741538660,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"AccountsPage.jsx:193","message":"handleFormSubmit: parsedData before save","data":{"hasStats":true,"itemsSold":null,"accountSellsValue":null},"timestamp":1768741540654,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"accountsService.js:72","message":"createManagedAccount: payload before Appwrite","data":{"account_sells":null,"accountData_account_sells":null},"timestamp":1768741540655,"sessionId":"debug-session","runId":"run1","hypothesisId":"E"}
{"location":"ebayParserService.js:292","message":"parseEbayAccount: route decision","data":{"extAvailable":true,"url":"https://www.ebay.de/str/goldbloom25?_trksid=p4429486.m3561.l161211"},"timestamp":1768741552261,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741552262,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:158","message":"parseViaExtension: chrome.runtime.sendMessage error","data":{"error":"Could not establish connection. Receiving end does not exist.","extensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741552269,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:299","message":"parseEbayAccount: extension error, using stub","data":{"error":"Could not establish connection. Receiving end does not exist."},"timestamp":1768741552269,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:304","message":"parseEbayAccount: stub result","data":{"itemsSold":null},"timestamp":1768741552270,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"AccountsPage.jsx:93","message":"handleRefreshAccount: update payload","data":{"payload":{"account_platform_market":"DE","account_platform_account_id":"ebay_0000uuvjdi","account_shop_name":"eBay Seller vjdi","account_sells":null}},"timestamp":1768741552270,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:133","message":"updateManagedAccount: before updateDocument","data":{"accountId":"696cdaa40028f011f5d0","payload":{"account_platform_market":"DE","account_platform_account_id":"ebay_0000uuvjdi","account_shop_name":"eBay Seller vjdi"},"payloadKeys":["account_platform_market","account_platform_account_id","account_shop_name"]},"timestamp":1768741552270,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:147","message":"updateManagedAccount: success","data":{"accountId":"696cdaa40028f011f5d0"},"timestamp":1768741552346,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"ebayParserService.js:292","message":"parseEbayAccount: route decision","data":{"extAvailable":true,"url":"https://www.ebay.de/str/ihaveitmusic?_trksid=p4429486.m3561.l161211"},"timestamp":1768741553567,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741553568,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:158","message":"parseViaExtension: chrome.runtime.sendMessage error","data":{"error":"Could not establish connection. Receiving end does not exist.","extensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741553574,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:299","message":"parseEbayAccount: extension error, using stub","data":{"error":"Could not establish connection. Receiving end does not exist."},"timestamp":1768741553574,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:304","message":"parseEbayAccount: stub result","data":{"itemsSold":null},"timestamp":1768741553574,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"AccountsPage.jsx:93","message":"handleRefreshAccount: update payload","data":{"payload":{"account_platform_market":"DE","account_platform_account_id":"ebay_0000h1nxit","account_shop_name":"eBay Seller nxit","account_sells":null}},"timestamp":1768741553575,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:133","message":"updateManagedAccount: before updateDocument","data":{"accountId":"696cbd07000703cf5437","payload":{"account_platform_market":"DE","account_platform_account_id":"ebay_0000h1nxit","account_shop_name":"eBay Seller nxit"},"payloadKeys":["account_platform_market","account_platform_account_id","account_shop_name"]},"timestamp":1768741553575,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:147","message":"updateManagedAccount: success","data":{"accountId":"696cbd07000703cf5437"},"timestamp":1768741553651,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"ebayParserService.js:292","message":"parseEbayAccount: route decision","data":{"extAvailable":true,"url":"https://www.ebay.de/str/goldbloom25?_trksid=p4429486.m3561.l161211"},"timestamp":1768741571041,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741571042,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:160","message":"parseViaExtension: response data from extension","data":{"hasStats":true,"itemsSold":1588,"stats":{"positiveRate":7,"feedbackCount":0,"itemsForSale":588,"itemsSold":1588}},"timestamp":1768741573909,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"AccountsPage.jsx:93","message":"handleRefreshAccount: update payload","data":{"payload":{"account_platform_market":"DE","account_platform_account_id":"goldbloom25","account_shop_name":"goldbloom25","account_sells":1588}},"timestamp":1768741573909,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:133","message":"updateManagedAccount: before updateDocument","data":{"accountId":"696cdaa40028f011f5d0","payload":{"account_platform_market":"DE","account_platform_account_id":"goldbloom25","account_shop_name":"goldbloom25","account_sells":1588},"payloadKeys":["account_platform_market","account_platform_account_id","account_shop_name","account_sells"]},"timestamp":1768741573909,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:147","message":"updateManagedAccount: success","data":{"accountId":"696cdaa40028f011f5d0"},"timestamp":1768741574001,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"ebayParserService.js:292","message":"parseEbayAccount: route decision","data":{"extAvailable":true,"url":"https://www.ebay.de/sch/i.html?item=397047173300&rt=nc&_trksid=p4429486.m3561.l161211&_ssn=miceusi"},"timestamp":1768741581711,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741581712,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:299","message":"parseEbayAccount: extension error, using stub","data":{"error":"timeout"},"timestamp":1768741596751,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"AccountsPage.jsx:93","message":"handleRefreshAccount: update payload","data":{"payload":{"account_platform_market":"DE","account_platform_account_id":"ebay_00002jm9ly","account_shop_name":"eBay Seller m9ly","account_sells":null}},"timestamp":1768741596752,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:133","message":"updateManagedAccount: before updateDocument","data":{"accountId":"696ccb2400395714987c","payload":{"account_platform_market":"DE","account_platform_account_id":"ebay_00002jm9ly","account_shop_name":"eBay Seller m9ly"},"payloadKeys":["account_platform_market","account_platform_account_id","account_shop_name"]},"timestamp":1768741596752,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"ebayParserService.js:304","message":"parseEbayAccount: stub result","data":{"itemsSold":null},"timestamp":1768741596752,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"accountsService.js:147","message":"updateManagedAccount: success","data":{"accountId":"696ccb2400395714987c"},"timestamp":1768741596841,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"ebayParserService.js:292","message":"parseEbayAccount: route decision","data":{"extAvailable":true,"url":"https://www.ebay.de/str/ihaveitmusic?_trksid=p4429486.m3561.l161211"},"timestamp":1768741610165,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768741610165,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:160","message":"parseViaExtension: response data from extension","data":{"hasStats":true,"itemsSold":280535,"stats":{"positiveRate":5,"feedbackCount":0,"itemsForSale":535,"itemsSold":280535}},"timestamp":1768741616427,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"accountsService.js:133","message":"updateManagedAccount: before updateDocument","data":{"accountId":"696cbd07000703cf5437","payload":{"account_platform_market":"DE","account_platform_account_id":"ihaveitmusic","account_shop_name":"iHaveit","account_sells":280535},"payloadKeys":["account_platform_market","account_platform_account_id","account_shop_name","account_sells"]},"timestamp":1768741616428,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"AccountsPage.jsx:93","message":"handleRefreshAccount: update payload","data":{"payload":{"account_platform_market":"DE","account_platform_account_id":"ihaveitmusic","account_shop_name":"iHaveit","account_sells":280535}},"timestamp":1768741616427,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"accountsService.js:147","message":"updateManagedAccount: success","data":{"accountId":"696cbd07000703cf5437"},"timestamp":1768741616513,"sessionId":"debug-session","runId":"run1","hypothesisId":"A"}
{"location":"ebayParserService.js:150","message":"getExtensionId: not found after retries","data":{"hasWindow":true},"timestamp":1768742766564,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:150","message":"getExtensionId: not found after retries","data":{"hasWindow":true},"timestamp":1768742773836,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742796643,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742817118,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742900325,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}
{"location":"ebayParserService.js:135","message":"getExtensionId: found via cache","data":{"cachedExtensionId":"ikldokdleojiinjklkhkkhfhpfafeaoc"},"timestamp":1768742905657,"sessionId":"debug-session","runId":"run1","hypothesisId":"D"}

View File

@@ -2,9 +2,14 @@ const STORAGE_KEY = "auth_jwt";
const BACKEND_URL = "http://localhost:5173"; // TODO: Backend URL konfigurieren const BACKEND_URL = "http://localhost:5173"; // TODO: Backend URL konfigurieren
const PARSE_TIMEOUT_MS = 15000; // 15 seconds const PARSE_TIMEOUT_MS = 15000; // 15 seconds
const SCAN_TIMEOUT_MS = 20000; // 20 seconds (seller listing pages can be slower) const SCAN_TIMEOUT_MS = 45000; // 45 seconds (listing with _ipg=240 can be slow)
const ACCOUNT_EXTENDED_TIMEOUT_MS = 15000; // 15 seconds per sub-scan
const activeParseRequests = new Map(); // Map<tabId, { timeout, originalSender, resolve }> const activeParseRequests = new Map(); // Map<tabId, { timeout, originalSender, resolve }>
const activeScanRequests = new Map(); // Map<tabId, { timeout, sendResponse }> const activeScanRequests = new Map(); // Map<tabId, { timeout, sendResponse }>;
const activeExtendedParseRequests = new Map(); // Map<requestId, { sendResponse, results }>
/** Aktueller Scan-Fortschritt für GET_SCAN_PROGRESS (Polling durch Web-App) */
let currentScanProgress = null;
// Messages from content script (der von der Web-App kommt) // Messages from content script (der von der Web-App kommt)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
@@ -48,7 +53,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// eBay Parsing Response (from eBay content script) // eBay Parsing Response (from eBay content script)
if (msg?.action === "PARSE_COMPLETE") { if (msg?.action === "PARSE_COMPLETE") {
handleParseComplete(sender.tab?.id, msg.data); const tabId = sender?.tab?.id;
handleParseComplete(tabId, msg.data);
return true; return true;
} }
}); });
@@ -64,6 +70,16 @@ chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
handleScanProductsRequest(msg.url, msg.accountId, sendResponse); handleScanProductsRequest(msg.url, msg.accountId, sendResponse);
return true; // async return true; // async
} }
if (msg?.action === "PARSE_ACCOUNT_EXTENDED" && msg.url) {
handleParseAccountExtendedRequest(msg.url, sendResponse);
return true; // async
}
if (msg?.action === "GET_SCAN_PROGRESS") {
sendResponse(currentScanProgress ?? { percent: 0, phase: "idle", total: 0, current: 0, complete: false });
return false;
}
}); });
/** /**
@@ -78,13 +94,19 @@ async function handleParseRequest(url, sendResponse) {
return; return;
} }
console.log("[BACKGROUND] Creating tab for parse request:", url);
// Create hidden tab // Create hidden tab
const tab = await chrome.tabs.create({ const tab = await chrome.tabs.create({
url: url, url: url,
active: false active: false
}); });
if (!tab?.id) {
sendResponse({ ok: false, error: "Tab could not be created" });
return;
}
const tabId = tab.id; const tabId = tab.id;
console.log("[BACKGROUND] Tab created:", tabId);
// Set up timeout // Set up timeout
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -104,23 +126,13 @@ async function handleParseRequest(url, sendResponse) {
// Tab is fully loaded // Tab is fully loaded
if (changeInfo.status === 'complete' && updatedTab.url) { if (changeInfo.status === 'complete' && updatedTab.url) {
chrome.tabs.onUpdated.removeListener(checkTabLoaded); chrome.tabs.onUpdated.removeListener(checkTabLoaded);
console.log("[BACKGROUND] Tab loaded, sending parse message:", updatedTab.url);
// Small delay to ensure DOM is ready // Wait longer for content script to auto-load (if it does)
// Content scripts from manifest.json might need more time in hidden tabs
setTimeout(() => { setTimeout(() => {
// Send parse message to content script sendParseMessageWithRetry(tabId, 0);
chrome.tabs.sendMessage(tabId, { action: "PARSE_EBAY" }) }, 2000); // 2 seconds delay - give content script time to auto-load
.then(response => {
if (response && response.ok && response.data) {
handleParseComplete(tabId, response.data);
} else {
cleanupParseRequest(tabId, null, { ok: false, error: "Parsing failed" });
}
})
.catch(err => {
console.error("Error sending parse message:", err);
cleanupParseRequest(tabId, null, { ok: false, error: "Content script error" });
});
}, 1000); // 1 second delay for DOM ready
} }
}; };
@@ -132,6 +144,185 @@ async function handleParseRequest(url, sendResponse) {
} }
} }
/**
* Checks if a URL is an eBay URL
* @param {string} url - URL to check
* @returns {boolean} - true if URL is an eBay URL
*/
function isEbayUrl(url) {
if (!url) return false;
const urlLower = url.toLowerCase();
return urlLower.includes('ebay.');
}
/**
* Injects the eBay content script manually if it's not already loaded
* @param {number} tabId - Tab ID
* @returns {Promise<boolean>} - true if injection successful or already loaded
*/
async function ensureContentScriptInjected(tabId) {
try {
// First, check if tab URL is an eBay URL
const tab = await chrome.tabs.get(tabId);
if (!isEbayUrl(tab.url)) {
console.log("[BACKGROUND] Tab is not an eBay URL:", tab.url);
return false;
}
// Check if content script is already loaded by sending a ping
try {
await chrome.tabs.sendMessage(tabId, { action: "PING" });
console.log("[BACKGROUND] Content script already loaded");
return true;
} catch (pingError) {
// Content script not loaded, try to inject it manually
console.log("[BACKGROUND] Content script not found, injecting manually...");
try {
// Get tab info to check frames
const tabInfo = await chrome.tabs.get(tabId);
// Inject full ebay-content-script.js so PING, PARSE_EBAY and PARSE_PRODUCT_LIST are all handled.
// The previous inline injection only handled PING+PARSE_EBAY; SCAN sends PARSE_PRODUCT_LIST.
try {
await chrome.scripting.executeScript({
target: { tabId: tabId, frameIds: [0] },
files: ["ebay-content-script.js"]
});
console.log("[BACKGROUND] ebay-content-script.js injected successfully");
} catch (injectError) {
console.error("[BACKGROUND] Failed to inject ebay-content-script:", injectError);
throw injectError;
}
// Also try injecting a simple test script to verify injection works
try {
await chrome.scripting.executeScript({
target: { tabId: tabId, frameIds: [0] },
func: () => {
console.log("[TEST-INJECTION] Test script executed in main frame");
}
});
} catch (testError) {
}
// Wait longer for the script to fully initialize and register message listeners
// Retry PING to verify the script is ready
// Also try sending to frameId 0 explicitly
for (let pingAttempt = 0; pingAttempt < 10; pingAttempt++) {
await new Promise(resolve => setTimeout(resolve, 300)); // 300ms between pings
try {
// Try sending to main frame explicitly
try {
await chrome.tabs.sendMessage(tabId, { action: "PING" }, { frameId: 0 });
} catch (frameErr) {
// Fallback: try without frameId
await chrome.tabs.sendMessage(tabId, { action: "PING" });
}
console.log("[BACKGROUND] Content script ready after injection");
return true;
} catch (pingErr) {
// Continue to next attempt
}
}
// Even if PING failed, return true - the script might still work
console.warn("[BACKGROUND] Content script injected but PING not responding");
return true;
} catch (injectError) {
console.error("[BACKGROUND] Failed to inject content script:", injectError);
return false;
}
}
} catch (error) {
console.error("[BACKGROUND] Error checking/injecting content script:", error);
return false;
}
}
/**
* Sends parse message to content script with retry mechanism
* @param {number} tabId - Tab ID
* @param {number} attempt - Current attempt number (0-based)
*/
async function sendParseMessageWithRetry(tabId, attempt) {
const maxAttempts = 3;
const retryDelay = 500; // 500ms between retries
try {
console.log(`[BACKGROUND] Sending parse message (attempt ${attempt + 1}/${maxAttempts}) to tab:`, tabId);
// On first attempt, ensure content script is injected
if (attempt === 0) {
const injected = await ensureContentScriptInjected(tabId);
if (!injected) {
throw new Error("Could not inject content script");
}
}
// Check if content script is injected by trying to send a ping
// If this fails, the content script might not be loaded yet
// Try sending to main frame explicitly first
let response;
try {
response = await chrome.tabs.sendMessage(tabId, { action: "PARSE_EBAY" }, { frameId: 0 });
} catch (frameErr) {
// Fallback: try without frameId
response = await chrome.tabs.sendMessage(tabId, { action: "PARSE_EBAY" });
}
console.log("[BACKGROUND] Parse response received:", response);
if (response && response.ok && response.data) {
handleParseComplete(tabId, response.data);
} else {
cleanupParseRequest(tabId, null, {
ok: false,
error: response?.error || "Parsing failed"
});
}
} catch (err) {
// Check if error is due to content script not being ready
const runtimeError = chrome.runtime.lastError?.message || "";
const isContentScriptError =
err.message?.includes("Could not establish connection") ||
err.message?.includes("Receiving end does not exist") ||
err.message?.includes("Could not inject content script") ||
runtimeError.includes("Receiving end does not exist") ||
runtimeError.includes("Could not establish connection");
console.log(`[BACKGROUND] Error sending parse message (attempt ${attempt + 1}):`, {
error: err.message,
runtimeError: runtimeError,
isContentScriptError: isContentScriptError
});
if (isContentScriptError && attempt < maxAttempts) {
// Content script not ready yet, retry after delay
console.log(`[BACKGROUND] Content script not ready, retrying in ${retryDelay}ms...`);
setTimeout(() => {
sendParseMessageWithRetry(tabId, attempt + 1);
}, retryDelay);
} else {
// Max retries reached or different error
const errorMessage = isContentScriptError
? "Content script konnte nicht geladen werden. Bitte Extension neu laden."
: (err.message || runtimeError || "Content script error");
cleanupParseRequest(tabId, null, {
ok: false,
error: errorMessage
});
}
}
}
/** /**
* Handles parse complete response from content script * Handles parse complete response from content script
*/ */
@@ -143,6 +334,7 @@ function handleParseComplete(tabId, data) {
* Cleans up parse request: closes tab, clears timeout, sends response * Cleans up parse request: closes tab, clears timeout, sends response
*/ */
async function cleanupParseRequest(tabId, data, error) { async function cleanupParseRequest(tabId, data, error) {
if (tabId == null) return;
const request = activeParseRequests.get(tabId); const request = activeParseRequests.get(tabId);
if (!request) return; if (!request) return;
@@ -174,9 +366,32 @@ async function cleanupParseRequest(tabId, data, error) {
} }
} }
/**
* Transform account URL (e.g. /str/topmons) to listing URL with 240 items per page.
* Example: https://www.ebay.de/str/topmons?... → https://www.ebay.de/sch/i.html?_ssn=topmons&store_name=topmons&_ipg=240
*/
function transformAccountUrlTo240ItemsUrl(accountUrl) {
try {
const url = new URL(accountUrl);
const pathname = url.pathname;
const strMatch = pathname.match(/\/str\/([^\/\?]+)/);
if (!strMatch || !strMatch[1]) {
throw new Error("Could not extract account name from URL (expected /str/{name})");
}
const accountName = strMatch[1];
const baseUrl = `${url.protocol}//${url.hostname}`;
const listingUrl = `${baseUrl}/sch/i.html?_ssn=${encodeURIComponent(accountName)}&store_name=${encodeURIComponent(accountName)}&_ipg=240`;
return listingUrl;
} catch (e) {
throw new Error(`Failed to transform account URL: ${e.message}`);
}
}
/** /**
* Handles eBay product scan request * Handles eBay product scan request
* Creates a hidden tab, waits for load, sends parse message to content script * Transforms account URL to listing URL with _ipg=240, creates hidden tab, parses, then loads each item in separate tab.
*/ */
async function handleScanProductsRequest(url, accountId, sendResponse) { async function handleScanProductsRequest(url, accountId, sendResponse) {
try { try {
@@ -186,12 +401,24 @@ async function handleScanProductsRequest(url, accountId, sendResponse) {
return; return;
} }
let targetUrl = url;
try {
targetUrl = transformAccountUrlTo240ItemsUrl(url);
console.log("[BACKGROUND] Scan using listing URL (240 items):", targetUrl);
} catch (transformErr) {
console.warn("[BACKGROUND] URL transform failed, using original URL:", transformErr.message);
// Fallback: use original URL (e.g. already /sch/ or non-/str/)
}
// Create hidden tab // Create hidden tab
const tab = await chrome.tabs.create({ const tab = await chrome.tabs.create({
url: url, url: targetUrl,
active: false active: false
}); });
if (!tab?.id) {
sendResponse({ ok: false, error: "Tab could not be created" });
return;
}
const tabId = tab.id; const tabId = tab.id;
// Set up timeout // Set up timeout
@@ -205,19 +432,51 @@ async function handleScanProductsRequest(url, accountId, sendResponse) {
sendResponse: sendResponse sendResponse: sendResponse
}); });
currentScanProgress = { percent: 0, phase: "listing", total: 0, current: 0, complete: false };
// Wait for tab to load, then send parse message // Wait for tab to load, then send parse message
const checkTabLoaded = (updatedTabId, changeInfo, updatedTab) => { const checkTabLoaded = (updatedTabId, changeInfo, updatedTab) => {
if (updatedTabId !== tabId) return; if (updatedTabId !== tabId) return;
// Tab is fully loaded // Tab is fully loaded
if (changeInfo.status === 'complete' && updatedTab.url) { if (changeInfo.status === 'complete' && updatedTab.url) {
chrome.tabs.onUpdated.removeListener(checkTabLoaded); chrome.tabs.onUpdated.removeListener(checkTabLoaded);
// Small delay to ensure DOM is ready // Small delay to ensure DOM is ready
setTimeout(() => { setTimeout(() => {
// Send parse message to content script sendScanMessageWithRetry(tabId, 0);
chrome.tabs.sendMessage(tabId, { action: "PARSE_PRODUCT_LIST" }) }, 1000); // 1 second delay for DOM ready
.then(response => { }
};
chrome.tabs.onUpdated.addListener(checkTabLoaded);
} catch (error) {
console.error("Error in handleScanProductsRequest:", error);
sendResponse({ ok: false, error: error.message || "Unknown error" });
}
}
/**
* Sends scan message to content script with retry mechanism
* @param {number} tabId - Tab ID
* @param {number} attempt - Current attempt number (0-based)
*/
async function sendScanMessageWithRetry(tabId, attempt) {
const maxAttempts = 3;
const retryDelay = 500; // 500ms between retries
try {
console.log(`[BACKGROUND] Sending scan message (attempt ${attempt + 1}/${maxAttempts}) to tab:`, tabId);
// On first attempt, ensure content script is injected
if (attempt === 0) {
const injected = await ensureContentScriptInjected(tabId);
if (!injected) {
throw new Error("Could not inject content script");
}
}
const response = await chrome.tabs.sendMessage(tabId, { action: "PARSE_PRODUCT_LIST" });
if (response && response.ok) { if (response && response.ok) {
// Prüfe ob items vorhanden und nicht leer // Prüfe ob items vorhanden und nicht leer
const items = response.items || response.data?.items || []; const items = response.items || response.data?.items || [];
@@ -234,8 +493,12 @@ async function handleScanProductsRequest(url, accountId, sendResponse) {
meta: meta meta: meta
}); });
} else { } else {
// Erfolg: sende items + meta if (currentScanProgress) {
handleScanComplete(tabId, { items, meta }); currentScanProgress.phase = "details";
currentScanProgress.total = items.length;
currentScanProgress.percent = 10;
}
await handleScanComplete(tabId, { items, meta });
} }
} else { } else {
// Fehler: sende error + meta // Fehler: sende error + meta
@@ -247,34 +510,155 @@ async function handleScanProductsRequest(url, accountId, sendResponse) {
meta: meta meta: meta
}); });
} }
}) } catch (err) {
.catch(err => { // Check if error is due to content script not being ready
console.error("Error sending parse message:", err); const runtimeError = chrome.runtime.lastError?.message || "";
cleanupScanRequest(tabId, null, { ok: false, error: "Content script error" }); const isContentScriptError =
err.message?.includes("Could not establish connection") ||
err.message?.includes("Receiving end does not exist") ||
err.message?.includes("Could not inject content script") ||
runtimeError.includes("Receiving end does not exist") ||
runtimeError.includes("Could not establish connection");
console.log(`[BACKGROUND] Error sending scan message (attempt ${attempt + 1}):`, {
error: err.message,
runtimeError: runtimeError,
isContentScriptError: isContentScriptError
}); });
}, 1000); // 1 second delay for DOM ready
if (isContentScriptError && attempt < maxAttempts) {
// Content script not ready yet, retry after delay
console.log(`[BACKGROUND] Content script not ready, retrying in ${retryDelay}ms...`);
setTimeout(() => {
sendScanMessageWithRetry(tabId, attempt + 1);
}, retryDelay);
} else {
// Max retries reached or different error
const errorMessage = isContentScriptError
? "Content script konnte nicht geladen werden. Bitte Extension neu laden."
: (err.message || runtimeError || "Content script error");
cleanupScanRequest(tabId, null, {
ok: false,
error: errorMessage,
meta: {
pageType: "unknown",
finalUrl: "",
attempts: attempt + 1,
reason: "content_script_error"
}
});
}
}
}
const ITEM_TAB_LOAD_TIMEOUT_MS = 3000;
/**
* Opens each item URL in a separate background tab, waits for load, parses detail page
* (title, price, currency, category, condition), merges into item, then closes tab.
*/
async function loadAndParseEachItemTab(items) {
if (!Array.isArray(items) || items.length === 0) return;
const total = items.length;
for (let i = 0; i < total; i++) {
const item = items[i];
if (!item || !item.url) continue;
try {
const tab = await chrome.tabs.create({
url: item.url,
active: false
});
if (!tab?.id) {
console.warn(`[BACKGROUND] Tab could not be created for item ${i + 1}, skipping`);
continue;
}
await new Promise((resolve) => {
const listener = (tabId, changeInfo) => {
if (tabId === tab.id && changeInfo.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
} }
}; };
chrome.tabs.onUpdated.addListener(listener);
chrome.tabs.onUpdated.addListener(checkTabLoaded); setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}, ITEM_TAB_LOAD_TIMEOUT_MS);
});
} catch (error) { const injected = await ensureContentScriptInjected(tab.id);
console.error("Error in handleScanProductsRequest:", error); if (!injected) {
sendResponse({ ok: false, error: error.message || "Unknown error" }); console.warn(`[BACKGROUND] Content script not ready in item tab ${i + 1}, skipping detail parse`);
}
try {
const response = injected
? await chrome.tabs.sendMessage(tab.id, { action: "PARSE_ITEM_DETAIL" })
: null;
if (response?.ok && response?.data) {
const d = response.data;
if (d.title != null && d.title !== "") item.title = d.title;
if (d.price != null) item.price = d.price;
if (d.currency != null && d.currency !== "") item.currency = d.currency;
if (d.category != null && d.category !== "") item.category = d.category;
if (d.condition != null && d.condition !== "") item.condition = d.condition;
if (d.quantityAvailable != null) item.quantityAvailable = d.quantityAvailable;
if (d.quantitySold != null) item.quantitySold = d.quantitySold;
if (d.watchCount != null) item.watchCount = d.watchCount;
if (d.inCartsCount != null) item.inCartsCount = d.inCartsCount;
}
} catch (parseErr) {
console.warn(`[BACKGROUND] PARSE_ITEM_DETAIL failed for ${item.url}:`, parseErr);
}
if (currentScanProgress && total > 0) {
currentScanProgress.current = i + 1;
currentScanProgress.percent = Math.min(99, 10 + Math.round((90 * (i + 1)) / total));
}
try {
await chrome.tabs.remove(tab.id);
} catch (removeErr) {
console.warn(`[BACKGROUND] Could not close item tab:`, removeErr);
}
} catch (e) {
console.warn(`[BACKGROUND] Failed to load/parse item tab ${i + 1}/${total}:`, item.url, e);
}
} }
} }
/** /**
* Handles scan complete response from content script * Handles scan complete response from content script
*/ */
function handleScanComplete(tabId, data) { async function handleScanComplete(tabId, data) {
cleanupScanRequest(tabId, data, null); const request = activeScanRequests.get(tabId);
if (request && request.timeout) {
clearTimeout(request.timeout);
request.timeout = null;
}
const items = data?.items || [];
if (items.length > 0) {
await loadAndParseEachItemTab(items);
}
if (currentScanProgress) {
currentScanProgress.percent = 100;
currentScanProgress.complete = true;
currentScanProgress.current = items.length;
}
await cleanupScanRequest(tabId, data, null);
} }
/** /**
* Cleans up scan request: closes tab, clears timeout, sends response * Cleans up scan request: closes tab, clears timeout, sends response
*/ */
async function cleanupScanRequest(tabId, data, error) { async function cleanupScanRequest(tabId, data, error) {
if (tabId == null) return;
const request = activeScanRequests.get(tabId); const request = activeScanRequests.get(tabId);
if (!request) return; if (!request) return;
@@ -286,6 +670,8 @@ async function cleanupScanRequest(tabId, data, error) {
// Remove from active requests // Remove from active requests
activeScanRequests.delete(tabId); activeScanRequests.delete(tabId);
currentScanProgress = null;
// Close tab (always, even on error) // Close tab (always, even on error)
try { try {
await chrome.tabs.remove(tabId); await chrome.tabs.remove(tabId);
@@ -309,6 +695,290 @@ async function cleanupScanRequest(tabId, data, error) {
} }
/**
* Handles extended account parsing request
* Visits multiple URLs (base, feedback, about, store) and collects partial results
*/
async function handleParseAccountExtendedRequest(url, sendResponse) {
try {
// Validate URL
if (!url || typeof url !== 'string' || !url.toLowerCase().includes('ebay.')) {
sendResponse({ ok: false, error: "Invalid eBay URL" });
return;
}
console.log("[BACKGROUND] Starting extended account parse for:", url);
const requestId = `extended_${Date.now()}_${Math.random()}`;
const results = {
base: { ok: false, data: null, error: null },
feedback: { ok: false, data: null, error: null },
about: { ok: false, data: null, error: null },
store: { ok: false, data: null, error: null }
};
activeExtendedParseRequests.set(requestId, {
sendResponse: sendResponse,
results: results
});
// Step 1: Parse base URL (like normal parse)
try {
const baseData = await parseSingleTab(url, "PARSE_EBAY", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.base = { ok: true, data: baseData, error: null };
} catch (error) {
console.warn("[BACKGROUND] Base parse failed:", error);
results.base = { ok: false, data: null, error: error.message || "Base parse failed" };
}
// Step 2: Parse feedback tab
try {
const feedbackUrl = appendQueryParam(url, "_tab=feedback");
const feedbackData = await parseSingleTab(feedbackUrl, "PARSE_FEEDBACK", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.feedback = { ok: true, data: feedbackData, error: null };
} catch (error) {
console.warn("[BACKGROUND] Feedback parse failed:", error);
results.feedback = { ok: false, data: null, error: error.message || "Feedback parse failed" };
}
// Step 3: Parse about tab
try {
const aboutUrl = appendQueryParam(url, "_tab=about");
const aboutData = await parseSingleTab(aboutUrl, "PARSE_ABOUT", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.about = { ok: true, data: aboutData, error: null };
} catch (error) {
console.warn("[BACKGROUND] About parse failed:", error);
results.about = { ok: false, data: null, error: error.message || "About parse failed" };
}
// Step 4: Find and parse store URL
try {
let storeUrl = null;
// First, try to find store link on base page
if (results.base.ok) {
try {
// Re-open base page tab to search for store link
const baseTab = await chrome.tabs.create({
url: url,
active: false
});
if (!baseTab?.id) {
throw new Error("Tab could not be created");
}
await new Promise((resolve) => {
const checkLoaded = (tabId, changeInfo) => {
if (tabId === baseTab.id && changeInfo.status === 'complete') {
chrome.tabs.onUpdated.removeListener(checkLoaded);
setTimeout(resolve, 2000);
}
};
chrome.tabs.onUpdated.addListener(checkLoaded);
setTimeout(resolve, 5000); // Max wait
});
const injected = await ensureContentScriptInjected(baseTab.id);
if (injected) {
try {
const linkResponse = await chrome.tabs.sendMessage(baseTab.id, { action: "FIND_STORE_LINK" });
if (linkResponse && linkResponse.ok && linkResponse.data && linkResponse.data.storeUrl) {
storeUrl = linkResponse.data.storeUrl;
}
} catch (e) {
console.warn("[BACKGROUND] Could not find store link via content script:", e);
}
}
await chrome.tabs.remove(baseTab.id);
} catch (e) {
console.warn("[BACKGROUND] Error searching for store link:", e);
}
}
// Fallback: heuristical derivation
if (!storeUrl) {
const sellerId = results.base.data?.sellerId || null;
storeUrl = deriveStoreUrl(url, sellerId);
}
if (storeUrl) {
const storeData = await parseSingleTab(storeUrl, "PARSE_STORE", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.store = { ok: true, data: storeData, error: null };
} else {
results.store = { ok: false, data: null, error: "Could not determine store URL" };
}
} catch (error) {
console.warn("[BACKGROUND] Store parse failed:", error);
results.store = { ok: false, data: null, error: error.message || "Store parse failed" };
}
// Combine all results
const combinedData = {
// Base data (always present if base.ok)
sellerId: results.base.data?.sellerId || "",
shopName: results.base.data?.shopName || "",
market: results.base.data?.market || "US",
status: results.base.data?.status || "unknown",
stats: results.base.data?.stats || {},
// Extended data (can be null)
responseTimeHours: results.about.data?.responseTimeHours || null,
followers: results.store.data?.followers || null,
feedbackTotal: results.feedback.data?.feedbackTotal || null,
feedback12mPositive: results.feedback.data?.feedback12mPositive || null,
feedback12mNeutral: results.feedback.data?.feedback12mNeutral || null,
feedback12mNegative: results.feedback.data?.feedback12mNegative || null,
// Partial results status
partialResults: {
base: { ok: results.base.ok, error: results.base.error },
feedback: { ok: results.feedback.ok, error: results.feedback.error },
about: { ok: results.about.ok, error: results.about.error },
store: { ok: results.store.ok, error: results.store.error }
}
};
activeExtendedParseRequests.delete(requestId);
sendResponse({ ok: true, data: combinedData });
} catch (error) {
console.error("Error in handleParseAccountExtendedRequest:", error);
sendResponse({ ok: false, error: error.message || "Unknown error" });
}
}
/**
* Parses a single tab with given action and timeout
* @param {string} url - URL to parse
* @param {string} action - Action to send to content script (PARSE_EBAY, PARSE_FEEDBACK, etc.)
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {Promise<object>} Parsed data
*/
async function parseSingleTab(url, action, timeoutMs) {
return new Promise(async (resolve, reject) => {
let tabId = null;
let timeoutId = null;
try {
// Create hidden tab
const tab = await chrome.tabs.create({
url: url,
active: false
});
if (!tab?.id) {
reject(new Error("Tab could not be created"));
return;
}
tabId = tab.id;
// Set up timeout
timeoutId = setTimeout(() => {
cleanupSingleTab(tabId);
reject(new Error("timeout"));
}, timeoutMs);
// Wait for tab to load
const checkTabLoaded = (updatedTabId, changeInfo) => {
if (updatedTabId !== tabId) return;
if (changeInfo.status === 'complete') {
chrome.tabs.onUpdated.removeListener(checkTabLoaded);
setTimeout(async () => {
try {
const injected = await ensureContentScriptInjected(tabId);
if (!injected) {
cleanupSingleTab(tabId);
reject(new Error("Could not inject content script"));
return;
}
const response = await chrome.tabs.sendMessage(tabId, { action: action });
cleanupSingleTab(tabId);
if (response && response.ok && response.data) {
resolve(response.data);
} else {
reject(new Error(response?.error || "Parsing failed"));
}
} catch (err) {
cleanupSingleTab(tabId);
reject(err);
}
}, 2000); // 2 second delay for DOM ready
}
};
chrome.tabs.onUpdated.addListener(checkTabLoaded);
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (tabId) cleanupSingleTab(tabId);
reject(error);
}
});
}
/**
* Cleans up a single tab
*/
async function cleanupSingleTab(tabId) {
try {
await chrome.tabs.remove(tabId);
} catch (err) {
// Tab might already be closed
console.warn("[BACKGROUND] Could not close tab:", err);
}
}
/**
* Appends a query parameter to a URL
* @param {string} url - Base URL
* @param {string} param - Query parameter (e.g. "_tab=feedback")
* @returns {string} URL with appended parameter
*/
function appendQueryParam(url, param) {
try {
const urlObj = new URL(url);
urlObj.search += (urlObj.search ? '&' : '?') + param;
return urlObj.href;
} catch (e) {
// Fallback: simple string append
return url + (url.includes('?') ? '&' : '?') + param;
}
}
/**
* Derives store URL from account URL
* @param {string} accountUrl - Account URL
* @param {string|null} sellerId - Seller ID if available
* @returns {string|null} Store URL or null
*/
function deriveStoreUrl(accountUrl, sellerId) {
try {
const urlObj = new URL(accountUrl);
const pathname = urlObj.pathname;
// Try to extract seller ID from URL if not provided
let id = sellerId;
if (!id) {
const usrMatch = pathname.match(/\/usr\/([^\/\?]+)/);
if (usrMatch && usrMatch[1]) {
id = usrMatch[1];
} else {
const strMatch = pathname.match(/\/str\/([^\/\?]+)/);
if (strMatch && strMatch[1]) {
id = strMatch[1];
}
}
}
if (id) {
return `${urlObj.protocol}//${urlObj.hostname}/str/${encodeURIComponent(id)}`;
}
return null;
} catch (e) {
console.warn("[BACKGROUND] Failed to derive store URL:", e);
return null;
}
}
export async function getJwt() { export async function getJwt() {
const data = await chrome.storage.local.get(STORAGE_KEY); const data = await chrome.storage.local.get(STORAGE_KEY);
return data[STORAGE_KEY] || ""; return data[STORAGE_KEY] || "";

View File

@@ -10,6 +10,10 @@ function setExtensionFlag() {
const hasRuntime = hasChrome && chrome.runtime; const hasRuntime = hasChrome && chrome.runtime;
const runtimeId = hasRuntime ? chrome.runtime.id : null; const runtimeId = hasRuntime ? chrome.runtime.id : null;
if (typeof window !== 'undefined' && hasChrome && hasRuntime && runtimeId) { if (typeof window !== 'undefined' && hasChrome && hasRuntime && runtimeId) {
// WICHTIG: Content Scripts haben einen isolierten Context
// window.__EBAY_EXTENSION__ wird im Content Script Context gesetzt,
// aber die Web-App läuft im Page Context - diese sind getrennt!
// Daher verlassen wir uns hauptsächlich auf postMessage
window.__EBAY_EXTENSION__ = true; window.__EBAY_EXTENSION__ = true;
window.__EBAY_EXTENSION_ID__ = runtimeId; // Extension-ID für chrome.runtime.sendMessage window.__EBAY_EXTENSION_ID__ = runtimeId; // Extension-ID für chrome.runtime.sendMessage
console.log('[ESHIP-CONTENT] window.__EBAY_EXTENSION__ set to true, ID:', runtimeId); console.log('[ESHIP-CONTENT] window.__EBAY_EXTENSION__ set to true, ID:', runtimeId);
@@ -23,6 +27,8 @@ function setExtensionFlag() {
// Versuche Flag sofort zu setzen // Versuche Flag sofort zu setzen
console.log('[ESHIP-CONTENT] Content script loaded'); console.log('[ESHIP-CONTENT] Content script loaded');
console.log('[ESHIP-CONTENT] URL:', window.location.href);
console.log('[ESHIP-CONTENT] ReadyState:', document.readyState);
if (!setExtensionFlag()) { if (!setExtensionFlag()) {
// Wenn window nicht verfügbar, warte auf DOMContentLoaded oder document.readyState // Wenn window nicht verfügbar, warte auf DOMContentLoaded oder document.readyState
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
@@ -35,6 +41,7 @@ if (!setExtensionFlag()) {
setExtensionFlag(); setExtensionFlag();
}, 0); }, 0);
} }
} else {
} }
// Sende Extension-ID an Web-App via postMessage (da Content Script Isolation verhindert, dass window-Properties geteilt werden) // Sende Extension-ID an Web-App via postMessage (da Content Script Isolation verhindert, dass window-Properties geteilt werden)
@@ -42,27 +49,105 @@ if (!setExtensionFlag()) {
function sendExtensionIdToWebApp() { function sendExtensionIdToWebApp() {
try { try {
const runtimeId = chrome.runtime?.id; const runtimeId = chrome.runtime?.id;
const hasRuntime = !!chrome.runtime;
const hasId = !!runtimeId;
const currentUrl = window.location.href;
if (runtimeId) { if (runtimeId) {
// Sende Extension-ID an Web-App const message = {
window.postMessage({
source: "eship-extension", source: "eship-extension",
type: "EXTENSION_ID", type: "EXTENSION_ID",
extensionId: runtimeId extensionId: runtimeId
}, "*"); };
// Sende Extension-ID an Web-App
window.postMessage(message, "*");
} else {
} }
} catch (e) { } catch (e) {
console.error('[ESHIP-CONTENT] Error sending extension ID:', e); console.error('[ESHIP-CONTENT] Error sending extension ID:', e);
} }
} }
// Sende Extension-ID beim Laden // Sende Extension-ID sofort beim Laden (wichtig für Seiten-Reload)
sendExtensionIdToWebApp(); sendExtensionIdToWebApp();
// Auch nach kurzer Verzögerung nochmal (falls Web-App noch nicht bereit ist)
// Sende auch nach kurzen Verzögerungen (falls Web-App noch nicht bereit ist)
setTimeout(sendExtensionIdToWebApp, 100);
setTimeout(sendExtensionIdToWebApp, 500); setTimeout(sendExtensionIdToWebApp, 500);
setTimeout(sendExtensionIdToWebApp, 1000);
setTimeout(sendExtensionIdToWebApp, 2000);
// Kontinuierlich postMessage senden, damit die Web-App die Extension erkennen kann
// (auch wenn der User die Extension NACH dem Laden der Seite installiert)
let extensionAnnounceInterval = setInterval(() => {
sendExtensionIdToWebApp();
}, 2000); // Alle 2 Sekunden senden
// Stoppe das Intervall nach 5 Minuten (um Ressourcen zu sparen, aber lange genug für Onboarding)
setTimeout(() => {
if (extensionAnnounceInterval) {
clearInterval(extensionAnnounceInterval);
extensionAnnounceInterval = null;
}
}, 300000); // 5 Minuten
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
// PING_EXTENSION Handler: Web-App fragt aktiv nach Extension
// Akzeptiere sowohl "eship-webapp" als auch "eship-webapp-page-context"
if ((event.data?.source === MESSAGE_SOURCE || event.data?.source === "eship-webapp-page-context") && event.data?.type === "PING_EXTENSION") {
// Verwende console.log als Fallback, da fetch möglicherweise fehlschlägt
console.log('[ESHIP-CONTENT] Received PING_EXTENSION, responding...', event.data);
// Antworte sofort mit Extension-ID
const runtimeId = chrome.runtime?.id;
console.log('[ESHIP-CONTENT] Runtime ID:', runtimeId);
if (runtimeId) {
const response = {
source: "eship-extension",
type: "EXTENSION_ID",
extensionId: runtimeId,
requestId: event.data?.requestId
};
console.log('[ESHIP-CONTENT] Sending response:', response);
window.postMessage(response, "*");
// Setze user_extension_load auf true im Backend
// Hole JWT aus Storage und sende an Backend
chrome.storage.local.get("auth_jwt").then((data) => {
const jwt = data.auth_jwt;
if (jwt) {
// Backend-Endpoint aufrufen
fetch("http://localhost:3000/api/user/set-extension-loaded", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${jwt}`
}
})
.then(res => res.json())
.then(result => {
console.log('[ESHIP-CONTENT] user_extension_load set to true:', result);
})
.catch(err => {
console.error('[ESHIP-CONTENT] Failed to set user_extension_load:', err);
});
} else {
console.warn('[ESHIP-CONTENT] No JWT found, cannot set user_extension_load');
}
});
} else {
console.error('[ESHIP-CONTENT] No runtime ID available!');
}
return;
}
// Sicherheitscheck: Nur Nachrichten von derselben Origin akzeptieren // Sicherheitscheck: Nur Nachrichten von derselben Origin akzeptieren
if (event.data?.source !== MESSAGE_SOURCE) return; if (event.data?.source !== MESSAGE_SOURCE && event.data?.source !== "eship-webapp-page-context") return;
// Auth Messages (JWT) // Auth Messages (JWT)
if (event.data.type === "AUTH_JWT" || event.data.type === "AUTH_CLEARED") { if (event.data.type === "AUTH_JWT" || event.data.type === "AUTH_CLEARED") {

View File

@@ -3,14 +3,50 @@
* Wird auf eBay-Seiten ausgeführt und extrahiert Verkäufer-/Shop-Daten aus dem DOM * Wird auf eBay-Seiten ausgeführt und extrahiert Verkäufer-/Shop-Daten aus dem DOM
*/ */
// Log dass Content Script geladen wurde
console.log("[EBAY-CONTENT] Content script loaded on:", window.location.href);
// Message Listener für Parsing-Anfragen // Message Listener für Parsing-Anfragen
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Ping-Handler für Content Script Verfügbarkeitsprüfung
if (message.action === "PING") {
sendResponse({ ok: true, ready: true });
return true;
}
if (message.action === "PARSE_EBAY") { if (message.action === "PARSE_EBAY") {
// Wrapper um sicherzustellen, dass immer eine Antwort gesendet wird
try {
// Prüfe ob DOM bereit ist
if (document.readyState === 'loading') {
// DOM noch nicht bereit, warte kurz
document.addEventListener('DOMContentLoaded', () => {
try { try {
const parsedData = parseEbayPage(); const parsedData = parseEbayPage();
sendResponse({ ok: true, data: parsedData }); sendResponse({ ok: true, data: parsedData });
} catch (error) {
console.error("[EBAY-CONTENT] Error parsing:", error);
sendResponse({
ok: true,
data: {
sellerId: "",
shopName: "",
market: extractMarketFromHostname(),
status: "unknown",
stats: {}
}
});
}
}, { once: true });
return true; // async response
}
// DOM ist bereit, parse sofort
const parsedData = parseEbayPage();
sendResponse({ ok: true, data: parsedData });
} catch (error) { } catch (error) {
// Niemals unhandled throws - immer graceful response // Niemals unhandled throws - immer graceful response
console.error("[EBAY-CONTENT] Error in parse handler:", error);
sendResponse({ sendResponse({
ok: true, ok: true,
data: { data: {
@@ -25,6 +61,61 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; // async response return true; // async response
} }
if (message.action === "PARSE_ITEM_DETAIL") {
try {
const detail = parseItemDetailPage();
sendResponse({ ok: true, data: detail });
} catch (e) {
console.warn("[EBAY-CONTENT] PARSE_ITEM_DETAIL error:", e);
sendResponse({ ok: true, data: {} });
}
return true;
}
if (message.action === "PARSE_FEEDBACK") {
try {
const feedbackData = extractFeedbackData();
sendResponse({ ok: true, data: feedbackData });
} catch (error) {
console.error("[EBAY-CONTENT] PARSE_FEEDBACK error:", error);
sendResponse({ ok: false, error: error.message || "Failed to extract feedback data" });
}
return true;
}
if (message.action === "PARSE_ABOUT") {
try {
const aboutData = extractResponseTime();
sendResponse({ ok: true, data: aboutData });
} catch (error) {
console.error("[EBAY-CONTENT] PARSE_ABOUT error:", error);
sendResponse({ ok: false, error: error.message || "Failed to extract response time" });
}
return true;
}
if (message.action === "PARSE_STORE") {
try {
const storeData = extractFollowers();
sendResponse({ ok: true, data: storeData });
} catch (error) {
console.error("[EBAY-CONTENT] PARSE_STORE error:", error);
sendResponse({ ok: false, error: error.message || "Failed to extract followers" });
}
return true;
}
if (message.action === "FIND_STORE_LINK") {
try {
const storeUrl = findStoreLink();
sendResponse({ ok: true, data: { storeUrl } });
} catch (error) {
console.error("[EBAY-CONTENT] FIND_STORE_LINK error:", error);
sendResponse({ ok: false, error: error.message || "Failed to find store link" });
}
return true;
}
if (message.action === "PARSE_PRODUCT_LIST") { if (message.action === "PARSE_PRODUCT_LIST") {
// async function, need to return promise // async function, need to return promise
parseProductList() parseProductList()
@@ -133,6 +224,12 @@ function extractSellerId() {
return strMatch[1].trim(); return strMatch[1].trim();
} }
// Pattern: _ssn=username (Seller Name Parameter in Suchergebnis-URLs)
const ssnMatch = url.match(/[?&]_ssn=([^&]+)/i);
if (ssnMatch && ssnMatch[1]) {
return decodeURIComponent(ssnMatch[1]).trim();
}
// Methode 2: DOM-Elemente suchen // Methode 2: DOM-Elemente suchen
// Suche nach verschiedenen Selektoren, die Seller-ID enthalten könnten // Suche nach verschiedenen Selektoren, die Seller-ID enthalten könnten
const possibleSelectors = [ const possibleSelectors = [
@@ -633,7 +730,6 @@ async function parseProductList() {
let pageType = detectPageType(); let pageType = detectPageType();
let finalUrl = window.location.href; let finalUrl = window.location.href;
let reason = null; let reason = null;
try { try {
// Schritt 1: Warte auf Item-Links (Polling) // Schritt 1: Warte auf Item-Links (Polling)
let links = await waitForItemLinks(4000, 250, 5); let links = await waitForItemLinks(4000, 250, 5);
@@ -718,17 +814,14 @@ async function parseProductList() {
} }
} }
// Return max 60 items (first page)
const result = parsedItems.slice(0, 60);
return { return {
ok: true, ok: true,
items: result, items: parsedItems,
meta: { meta: {
pageType, pageType,
finalUrl, finalUrl,
attempts, attempts,
reason: reason || (result.length > 0 ? "items_found" : "parsed_zero_items") reason: reason || (parsedItems.length > 0 ? "items_found" : "parsed_zero_items")
} }
}; };
@@ -746,66 +839,103 @@ async function parseProductList() {
}; };
} }
} }
/**
* Parst Produktdetail-Seite (/itm/...) für title, price, currency, category, condition,
* quantityAvailable, quantitySold, watchCount, inCartsCount.
*/
function parseItemDetailPage() {
const out = {
title: "",
price: null,
currency: null,
category: null,
condition: null,
quantityAvailable: null,
quantitySold: null,
watchCount: null,
inCartsCount: null
};
try { try {
const url = window.location.href; const titleEl = document.querySelector("[data-testid=\"x-item-title\"] h1 .ux-textspans") ||
const urlLower = url.toLowerCase(); document.querySelector("h1.x-item-title__mainTitle .ux-textspans");
if (titleEl) {
out.title = (titleEl.textContent || "").trim().replace(/\s+/g, " ");
}
// Determine page type const priceEl = document.querySelector("[data-testid=\"x-price-primary\"] .ux-textspans");
const isStorePage = urlLower.includes('/str/') || urlLower.includes('/store/'); if (priceEl) {
const isSellerPage = urlLower.includes('/usr/'); const priceText = (priceEl.textContent || "").trim();
const parsed = parsePrice(priceText);
out.price = parsed.price;
out.currency = parsed.currency;
}
// Check if seller profile without items (try to find link to listings) const breadcrumbs = document.querySelectorAll("a.seo-breadcrumb-text span");
if (isSellerPage && !isStorePage) { if (breadcrumbs.length > 0) {
const itemsLink = document.querySelector('a[href*="/usr/"][href*="?items="]') || const parts = [];
document.querySelector('a[href*="schid=mksr"]') || breadcrumbs.forEach((span) => {
Array.from(document.querySelectorAll('a')).find(a => { const t = (span.textContent || "").trim();
const text = (a.textContent || '').toLowerCase(); if (t) parts.push(t);
return text.includes('artikel') || text.includes('angebote') ||
text.includes('items for sale') || text.includes('see all items');
}); });
out.category = parts.join(" > ");
}
if (!itemsLink) { const condEl = document.querySelector("dd.ux-labels-values__values .ux-textspans");
// Try to find item cards directly if (condEl) {
const hasItems = findItemLinks().length > 0; const raw = (condEl.textContent || "").trim();
if (!hasItems) { const colonIdx = raw.indexOf(":");
throw new Error("no_items_page"); out.condition = colonIdx >= 0 ? raw.slice(0, colonIdx).trim() : raw.slice(0, 80);
}
// x-quantity__availability: "9 verfügbar", "8 verkauft"
const availEl = document.querySelector(".x-quantity__availability");
if (availEl) {
const text = (availEl.textContent || "").trim();
const verfMatch = text.match(/(\d+)\s*verfügbar/i);
if (verfMatch && verfMatch[1]) {
const n = parseInt(verfMatch[1], 10);
if (!isNaN(n) && n >= 0) out.quantityAvailable = n;
}
const verkMatch = text.match(/(\d+)\s*verkauft/i);
if (verkMatch && verkMatch[1]) {
const n = parseInt(verkMatch[1], 10);
if (!isNaN(n) && n >= 0) out.quantitySold = n;
}
}
// x-watch-heart-btn: .x-watch-heart-btn-text "14" oder aria-label "14 Beobachter"
const watchBtn = document.querySelector("button.x-watch-heart-btn");
if (watchBtn) {
const textEl = watchBtn.querySelector(".x-watch-heart-btn-text");
if (textEl) {
const n = parseInt((textEl.textContent || "").trim().replace(/\D/g, ""), 10);
if (!isNaN(n) && n >= 0) out.watchCount = n;
}
if (out.watchCount == null) {
const label = (watchBtn.getAttribute("aria-label") || "").trim();
const m = label.match(/(\d+)\s*Beobachter/i);
if (m && m[1]) {
const n = parseInt(m[1], 10);
if (!isNaN(n) && n >= 0) out.watchCount = n;
} }
} }
} }
// Extract items // [data-testid="x-ebay-signal"]: "In 8 Warenkörben"
const items = findItemLinks(); const signalEl = document.querySelector("[data-testid=\"x-ebay-signal\"]");
if (signalEl) {
if (items.length === 0) { const text = (signalEl.textContent || "").trim();
throw new Error("no_items_found"); const m = text.match(/in\s*(\d+)\s*warenkörben/i);
if (m && m[1]) {
const n = parseInt(m[1], 10);
if (!isNaN(n) && n >= 0) out.inCartsCount = n;
} }
// Parse each item
const parsedItems = [];
const seenIds = new Set();
for (const itemLink of items) {
try {
const item = parseItemFromLink(itemLink);
// Deduplicate by platformProductId
if (item.platformProductId && !seenIds.has(item.platformProductId)) {
seenIds.add(item.platformProductId);
parsedItems.push(item);
} }
} catch (e) { } catch (e) {
// Continue with next item if one fails console.warn("[EBAY-CONTENT] parseItemDetailPage error:", e);
console.warn("Failed to parse item:", e);
}
}
// Return max 60 items (first page)
return parsedItems.slice(0, 60);
} catch (error) {
// Re-throw to be caught by message handler
throw error;
} }
return out;
} }
/** /**
@@ -965,6 +1095,325 @@ function parseItemFromLink(itemLink) {
return item; return item;
} }
/**
* Extrahiert Feedback-Daten von ?_tab=feedback Seite
* Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex
* @returns {object} { feedbackTotal, feedback12mPositive, feedback12mNeutral, feedback12mNegative }
*/
function extractFeedbackData() {
const result = {
feedbackTotal: null,
feedback12mPositive: null,
feedback12mNeutral: null,
feedback12mNegative: null
};
try {
const bodyText = document.body?.innerText || "";
// Strategy 1: Label-basiert - Suche "Verkäuferbewertungen" / "Gesamtbewertungen"
const totalMatch = bodyText.match(/(?:Verkäuferbewertungen|Gesamtbewertungen)[\s\S]*?\(([\d.,]+)\)/i);
if (totalMatch && totalMatch[1]) {
result.feedbackTotal = parseNumberWithSeparators(totalMatch[1]);
}
// Strategy 2: Label-basiert - Suche "positive Bewertungen der letzten 12 Monate"
const positiveMatch = bodyText.match(/(\d+[\d.,]*)\s*(?:positive|positive\s*Bewertungen)\s*der\s*letzten\s*12\s*Monate/i);
if (positiveMatch && positiveMatch[1]) {
result.feedback12mPositive = parseNumberWithSeparators(positiveMatch[1]);
}
// Strategy 3: Selector-Fallback - Bekannte Klassen
try {
const titleEl = document.querySelector('h2.fdbk-detail-list__title, h2[class*="fdbk-detail-list__title"]');
if (titleEl) {
const titleText = titleEl.textContent || "";
const match = titleText.match(/\(([\d.,]+)\)/);
if (match && match[1] && !result.feedbackTotal) {
result.feedbackTotal = parseNumberWithSeparators(match[1]);
}
}
const ratingDetails = document.querySelector('.fdbk-overall-rating__details, [class*="fdbk-overall-rating__details"]');
if (ratingDetails) {
const links = ratingDetails.querySelectorAll('a');
for (const link of links) {
const linkText = link.textContent || "";
const numberMatch = linkText.match(/(\d+[\d.,]*)/);
if (numberMatch && numberMatch[1]) {
const num = parseNumberWithSeparators(numberMatch[1]);
if (linkText.toLowerCase().includes('positiv') && !result.feedback12mPositive) {
result.feedback12mPositive = num;
} else if (linkText.toLowerCase().includes('neutral') && !result.feedback12mNeutral) {
result.feedback12mNeutral = num;
} else if (linkText.toLowerCase().includes('negativ') && !result.feedback12mNegative) {
result.feedback12mNegative = num;
}
}
}
}
} catch (e) {
// Continue to regex fallback
}
// Strategy 4: Regex-Fallback auf bodyText
if (!result.feedback12mPositive) {
const posMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*positive\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i);
if (posMatch && posMatch[1]) {
result.feedback12mPositive = parseNumberWithSeparators(posMatch[1]);
}
}
if (!result.feedback12mNeutral) {
const neuMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*neutrale\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i);
if (neuMatch && neuMatch[1]) {
result.feedback12mNeutral = parseNumberWithSeparators(neuMatch[1]);
}
}
if (!result.feedback12mNegative) {
const negMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*negative\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i);
if (negMatch && negMatch[1]) {
result.feedback12mNegative = parseNumberWithSeparators(negMatch[1]);
}
}
} catch (e) {
console.warn("[EBAY-CONTENT] Error extracting feedback data:", e);
}
return result;
}
/**
* Extrahiert Response Time von ?_tab=about Seite
* Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex
* @returns {object} { responseTimeHours }
*/
function extractResponseTime() {
const result = {
responseTimeHours: null
};
try {
const bodyText = document.body?.innerText || "";
// Strategy 1: Label-basiert - Suche "Antwortzeit:"
const labelMatch = bodyText.match(/Antwortzeit[:\s]+(?:innerhalb\s+)?(\d+)\s*(?:Stunde|Stunden)/i);
if (labelMatch && labelMatch[1]) {
result.responseTimeHours = parseInt(labelMatch[1], 10);
if (!isNaN(result.responseTimeHours) && result.responseTimeHours > 0) {
return result;
}
}
// Strategy 2: Selector-Fallback
try {
const spans = document.querySelectorAll('span.str-text-span, span[class*="str-text-span"]');
for (const span of spans) {
const spanText = span.textContent || "";
if (spanText.includes('Antwortzeit')) {
const nextSpan = span.nextElementSibling;
if (nextSpan) {
const timeText = nextSpan.textContent || "";
const match = timeText.match(/(?:innerhalb\s+)?(\d+)\s*(?:Stunde|Stunden)/i);
if (match && match[1]) {
const hours = parseInt(match[1], 10);
if (!isNaN(hours) && hours > 0) {
result.responseTimeHours = hours;
return result;
}
}
}
}
}
} catch (e) {
// Continue to regex fallback
}
// Strategy 3: Regex-Fallback
const regexMatch = bodyText.match(/innerhalb\s+(\d+)\s*(?:Stunde|Stunden)/i);
if (regexMatch && regexMatch[1]) {
const hours = parseInt(regexMatch[1], 10);
if (!isNaN(hours) && hours > 0) {
result.responseTimeHours = hours;
}
}
} catch (e) {
console.warn("[EBAY-CONTENT] Error extracting response time:", e);
}
return result;
}
/**
* Extrahiert Follower von Store-Seite
* Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex
* @returns {object} { followers }
*/
function extractFollowers() {
const result = {
followers: null
};
try {
const bodyText = document.body?.innerText || "";
// Strategy 1: Label-basiert - Suche "Follower"
const followerMatch = bodyText.match(/(\d+[\d.,]*)\s*Follower/i);
if (followerMatch && followerMatch[1]) {
result.followers = parseNumberWithSeparators(followerMatch[1]);
if (result.followers !== null) {
return result;
}
}
// Strategy 2: Selector-Fallback
try {
const statsContainer = document.querySelector('.str-seller-card__store-stats-content, [class*="str-seller-card__store-stats-content"]');
if (statsContainer) {
const divs = statsContainer.querySelectorAll('div');
for (const div of divs) {
const divText = div.textContent || "";
if (divText.includes('Follower')) {
const boldSpan = div.querySelector('span.BOLD, span[class*="BOLD"], .str-text-span.BOLD');
if (boldSpan) {
const followerText = boldSpan.textContent || "";
result.followers = parseNumberWithSeparators(followerText);
if (result.followers !== null) {
return result;
}
}
// Fallback: regex on div text
const match = divText.match(/(\d+[\d.,]*)\s*Follower/i);
if (match && match[1]) {
result.followers = parseNumberWithSeparators(match[1]);
if (result.followers !== null) {
return result;
}
}
}
}
}
} catch (e) {
// Continue to regex fallback
}
// Strategy 3: Regex-Fallback auf bodyText (wenn noch nicht gefunden)
if (result.followers === null) {
const regexMatch = bodyText.match(/(\d+[\d.,]*)\s*Follower/i);
if (regexMatch && regexMatch[1]) {
result.followers = parseNumberWithSeparators(regexMatch[1]);
}
}
} catch (e) {
console.warn("[EBAY-CONTENT] Error extracting followers:", e);
}
return result;
}
/**
* Findet Store-URL auf der aktuellen Seite
* Sucht nach Links mit Text "Store" / "Shop" oder href enthält "/str/"
* @returns {string|null} Store URL oder null
*/
function findStoreLink() {
try {
// Strategy 1: Suche Links mit Text "Store" / "Shop"
const allLinks = document.querySelectorAll('a[href]');
for (const link of allLinks) {
const linkText = (link.textContent || "").toLowerCase().trim();
const href = link.href || link.getAttribute('href') || "";
if (href.includes('/str/')) {
// Absoluter oder relativer URL
try {
const url = new URL(href, window.location.origin);
if (url.hostname.includes('ebay.')) {
return url.href;
}
} catch (e) {
// Invalid URL, continue
}
}
// Prüfe Link-Text
if ((linkText.includes('store') || linkText.includes('shop')) && href.includes('/str/')) {
try {
const url = new URL(href, window.location.origin);
if (url.hostname.includes('ebay.')) {
return url.href;
}
} catch (e) {
// Invalid URL, continue
}
}
}
// Strategy 2: Suche nach href mit /str/ Pattern
for (const link of allLinks) {
const href = link.href || link.getAttribute('href') || "";
if (href.includes('/str/')) {
try {
const url = new URL(href, window.location.origin);
if (url.hostname.includes('ebay.')) {
return url.href;
}
} catch (e) {
// Invalid URL, continue
}
}
}
return null;
} catch (e) {
console.warn("[EBAY-CONTENT] Error finding store link:", e);
return null;
}
}
/**
* Parst eine Zahl mit Tausendertrennern und "Mio." Unterstützung
* @param {string} text - Zahl-String z.B. "884.318" oder "2,4 Mio."
* @returns {number|null} Geparste Zahl oder null
*/
function parseNumberWithSeparators(text) {
if (!text || typeof text !== 'string') return null;
try {
// Entferne Leerzeichen
let normalized = text.trim().replace(/\s/g, '');
// Prüfe auf "Mio." / "Millionen"
const mioMatch = normalized.match(/([\d,]+)\s*[,.]?\s*(?:Mio|Millionen)/i);
if (mioMatch && mioMatch[1]) {
const numStr = mioMatch[1].replace(/,/g, '.');
const num = parseFloat(numStr);
if (!isNaN(num)) {
return Math.round(num * 1000000);
}
}
// Entferne Tausendertrenner (Punkte und Kommas)
normalized = normalized.replace(/\./g, ''); // Entferne Punkte (Tausendertrenner)
normalized = normalized.replace(/,/g, '.'); // Ersetze Kommas durch Punkte (Dezimaltrenner)
// Nur Digits und einen Dezimalpunkt behalten
normalized = normalized.replace(/[^\d.]/g, '');
// Parse zu Integer (für Follower/Feedback sind nur ganze Zahlen relevant)
const num = parseInt(normalized, 10);
if (!isNaN(num) && num >= 0) {
return num;
}
return null;
} catch (e) {
return null;
}
}
/** /**
* Parst Preis-String in Zahl und Währung * Parst Preis-String in Zahl und Währung
* @param {string} priceText - Preis-String z.B. "EUR 12,99" oder "$15.50" * @param {string} priceText - Preis-String z.B. "EUR 12,99" oder "$15.50"

View File

@@ -102,6 +102,10 @@ VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
VITE_APPWRITE_PROJECT_ID=696b82bb0036d2e547ad VITE_APPWRITE_PROJECT_ID=696b82bb0036d2e547ad
``` ```
### CORS / Production (z. B. https://www.eship.pro)
Wenn die App unter einer anderen Domain läuft als im Appwrite-Projekt eingetragen, blockiert der Browser die Requests (CORS). **Lösung:** Im [Appwrite Console](https://appwrite.io/docs/console) unter dem Projekt → **Auth****Settings****Platforms** die genaue App-URL als Plattform hinzufügen (z. B. `https://www.eship.pro`). Ohne diesen Eintrag bleibt `Access-Control-Allow-Origin` auf einer anderen Domain (z. B. `https://webklar.com`) und Anfragen von eship.pro schlagen fehl.
### Extension Backend URL (Extension/background.js) ### Extension Backend URL (Extension/background.js)
```javascript ```javascript
const BACKEND_URL = "http://localhost:3000"; // Anpassen falls nötig const BACKEND_URL = "http://localhost:3000"; // Anpassen falls nötig

785
Server/backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,785 @@
{
"name": "eship-backend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eship-backend",
"version": "0.1.0",
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"node-appwrite": "^14.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node-appwrite": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz",
"integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==",
"dependencies": {
"node-fetch-native-with-agent": "1.7.2"
}
},
"node_modules/node-fetch-native-with-agent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz",
"integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g=="
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
}
}
}

View File

@@ -49,6 +49,41 @@ app.post("/api/action", async (req, res) => {
} }
}); });
// Endpoint: Setze user_extension_load auf true
app.post("/api/user/set-extension-loaded", async (req, res) => {
try {
const auth = req.headers.authorization || "";
const jwt = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!jwt) return res.status(401).json({ ok: false, error: "missing token" });
// 1) User token validieren
const userClient = makeUserClient(jwt);
const account = new Account(userClient);
const user = await account.get();
// 2) User-Dokument mit Admin Key aktualisieren
const adminClient = makeAdminClient();
const db = new Databases(adminClient);
const databaseId = process.env.APPWRITE_DATABASE_ID || "eship-db";
const usersCollectionId = process.env.APPWRITE_USERS_COLLECTION_ID || "users";
// Update user document: setze user_extension_load auf true
await db.updateDocument(
databaseId,
usersCollectionId,
user.$id,
{
user_extension_load: true
}
);
return res.json({ ok: true, userId: user.$id });
} catch (e) {
console.error("Error in /api/user/set-extension-loaded:", e);
return res.status(401).json({ ok: false, error: "unauthorized" });
}
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Backend server running on port ${PORT}`); console.log(`Backend server running on port ${PORT}`);
}); });

View File

@@ -0,0 +1,8 @@
# Platform-Logos
Füge hier Logo-Dateien ein (PNG oder SVG, transparent, möglichst hohe Auflösung):
- `ebay.png` eBay-Logo („dickes“ Wortmarken-Logo)
- `amazon.png` Amazon-Logo
Ohne lokale Dateien werden Fallback-Logos (Wikimedia Commons) genutzt.

BIN
Server/public/extension.zip Normal file

Binary file not shown.

View File

@@ -3,24 +3,49 @@ import React, { useEffect, useState } from "react";
import { Sidebar, SidebarBody, SidebarLink } from "./components/sidebar"; import { Sidebar, SidebarBody, SidebarLink } from "./components/sidebar";
import { import {
IconArrowLeft, IconArrowLeft,
IconBan,
IconBrandTabler, IconBrandTabler,
IconChartLine,
IconSettings, IconSettings,
IconUserBolt, IconUserBolt,
IconShoppingBag, IconShoppingBag,
IconRobot,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
import { BackgroundRippleEffect } from "./components/layout/BackgroundRippleEffect"; import { BackgroundRippleEffect } from "./components/layout/BackgroundRippleEffect";
import { Dashboard } from "./components/dashboard/Dashboard"; import { Dashboard } from "./components/dashboard/Dashboard";
import { AccountsPage } from "./pages/AccountsPage"; import { AccountsPage } from "./pages/AccountsPage";
import { ItemsPage } from "./pages/ItemsPage";
import { BlacklistPage } from "./pages/BlacklistPage";
import { AnalysisPage } from "./pages/AnalysisPage";
import LogoutButton from "./components/ui/LogoutButton"; import LogoutButton from "./components/ui/LogoutButton";
import { OnboardingGate } from "./components/onboarding/OnboardingGate"; import { OnboardingGate } from "./components/onboarding/OnboardingGate";
import { SidebarHeader } from "./components/sidebar/SidebarHeader"; import { SidebarHeader } from "./components/sidebar/SidebarHeader";
import { useHashRoute } from "./lib/routing"; import { useHashRoute } from "./lib/routing";
import { account, databases, databaseId, usersCollectionId } from "./lib/appwrite"; import { account, databases, databaseId, usersCollectionId } from "./lib/appwrite";
import { fetchManagedAccounts } from "./services/accountsService";
import { useScan } from "./context/ScanContext";
import ScanningLoader from "./components/ui/ScanningLoader";
/** Prüft, ob der Fehler wie ein CORS- oder Netzwerkfehler aussieht (Request wird vom Browser blockiert). */
function isCorsOrNetworkError(e) {
const msg = (e?.message || "").toLowerCase();
return (
msg.includes("failed to fetch") ||
msg.includes("network error") ||
msg.includes("network request failed") ||
msg.includes("networkrequestfailed") ||
msg.includes("load failed") ||
msg.includes("err_failed") ||
(e?.name && e.name.toLowerCase().includes("network")) ||
(e?.type === "error" && !msg)
);
}
export default function App() { export default function App() {
const { route, navigate } = useHashRoute(); const { route, navigate } = useHashRoute();
const { scanning, scanProgress } = useScan();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [status, setStatus] = useState({ loading: true, authed: false, error: "" }); const [status, setStatus] = useState({ loading: true, authed: false, error: "" });
@@ -32,8 +57,15 @@ export default function App() {
const [checkingUserDoc, setCheckingUserDoc] = useState(false); const [checkingUserDoc, setCheckingUserDoc] = useState(false);
const [onboardingLoading, setOnboardingLoading] = useState(false); const [onboardingLoading, setOnboardingLoading] = useState(false);
const [onboardingError, setOnboardingError] = useState(""); const [onboardingError, setOnboardingError] = useState("");
const [userExtensionLoad, setUserExtensionLoad] = useState(null); // null = nicht geprüft, true/false = geprüft
const [hasAccounts, setHasAccounts] = useState(false); // true wenn User Accounts hat
const showGate = !hasUserDoc && !checkingUserDoc && authUser !== null; // Gate soll angezeigt werden, wenn:
// 1. User-Dokument nicht existiert ODER
// 2. User-Dokument existiert, aber user_extension_load = false UND keine Accounts vorhanden
// Gate wird versteckt, wenn:
// - User-Dokument existiert UND (user_extension_load = true ODER Accounts vorhanden)
const showGate = (!hasUserDoc || (hasUserDoc && userExtensionLoad === false && !hasAccounts)) && !checkingUserDoc && authUser !== null;
async function checkUserDocument(userId) { async function checkUserDocument(userId) {
if (!databases || !databaseId || !usersCollectionId) { if (!databases || !databaseId || !usersCollectionId) {
@@ -66,13 +98,56 @@ export default function App() {
// Prüfe, ob User-Dokument existiert // Prüfe, ob User-Dokument existiert
const userDocExists = await checkUserDocument(user.$id); const userDocExists = await checkUserDocument(user.$id);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:68',message:'refreshAuth: userDocExists check',data:{userId:user.$id,userDocExists},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
// #endregion
setHasUserDoc(userDocExists); setHasUserDoc(userDocExists);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:71',message:'refreshAuth: hasUserDoc set',data:{hasUserDoc:userDocExists,showGateWillBe:!userDocExists && !checkingUserDoc && user !== null},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
// #endregion
// Prüfe user_extension_load und Accounts, wenn User-Dokument existiert
if (userDocExists && databases && databaseId && usersCollectionId) {
try {
const userDoc = await databases.getDocument(databaseId, usersCollectionId, user.$id);
const extensionLoad = userDoc?.user_extension_load === true;
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:78',message:'refreshAuth: userDoc retrieved for extension check',data:{userExtensionLoad:userDoc?.user_extension_load,userExtensionLoadType:typeof userDoc?.user_extension_load,userExtensionLoadStrictFalse:userDoc?.user_extension_load === false,extensionLoad},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
// #endregion
setUserExtensionLoad(extensionLoad);
// Prüfe auch Accounts
try {
const existingAccounts = await fetchManagedAccounts(user.$id);
const hasAccountsValue = Array.isArray(existingAccounts) && existingAccounts.length > 0;
setHasAccounts(hasAccountsValue);
} catch (accountsErr) {
setHasAccounts(false);
}
} catch (docErr) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:82',message:'refreshAuth: error getting userDoc for extension check',data:{error:docErr.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
// #endregion
// Bei Fehler: Setze auf null (nicht geprüft)
setUserExtensionLoad(null);
setHasAccounts(false);
}
} else {
// Kein User-Dokument → user_extension_load ist nicht relevant
setUserExtensionLoad(null);
setHasAccounts(false);
}
await handoffJwtToExtension(); await handoffJwtToExtension();
} catch (e) { } catch (e) {
setStatus({ loading: false, authed: false, error: "" }); const errorMsg = isCorsOrNetworkError(e)
? "Verbindung zum Auth-Server fehlgeschlagen. Bitte in Appwrite die aktuelle App-URL (z. B. https://www.eship.pro) unter Platforms eintragen (CORS)."
: "";
setStatus({ loading: false, authed: false, error: errorMsg });
setAuthUser(null); setAuthUser(null);
setHasUserDoc(false); setHasUserDoc(false);
setUserExtensionLoad(null);
setHasAccounts(false);
} finally { } finally {
setCheckingUserDoc(false); setCheckingUserDoc(false);
} }
@@ -94,18 +169,53 @@ export default function App() {
const userDocExists = await checkUserDocument(user.$id); const userDocExists = await checkUserDocument(user.$id);
setHasUserDoc(userDocExists); setHasUserDoc(userDocExists);
// Prüfe user_extension_load und Accounts, wenn User-Dokument existiert
if (userDocExists && databases && databaseId && usersCollectionId) {
try {
const userDoc = await databases.getDocument(databaseId, usersCollectionId, user.$id);
const extensionLoad = userDoc?.user_extension_load === true;
setUserExtensionLoad(extensionLoad);
// Prüfe auch Accounts
try {
const existingAccounts = await fetchManagedAccounts(user.$id);
const hasAccountsValue = Array.isArray(existingAccounts) && existingAccounts.length > 0;
setHasAccounts(hasAccountsValue);
} catch (accountsErr) {
setHasAccounts(false);
}
} catch (docErr) {
setUserExtensionLoad(null);
setHasAccounts(false);
}
} else {
setUserExtensionLoad(null);
setHasAccounts(false);
}
await handoffJwtToExtension(); await handoffJwtToExtension();
} catch (e) { } catch (e) {
setStatus({ loading: false, authed: false, error: "Login fehlgeschlagen" }); const errorMsg = isCorsOrNetworkError(e)
? "Verbindung blockiert (CORS). In Appwrite unter Auth → Platforms die App-URL (z. B. https://www.eship.pro) hinzufügen."
: "Login fehlgeschlagen";
setStatus({ loading: false, authed: false, error: errorMsg });
setAuthUser(null); setAuthUser(null);
setHasUserDoc(false); setHasUserDoc(false);
setUserExtensionLoad(null);
setHasAccounts(false);
} finally { } finally {
setCheckingUserDoc(false); setCheckingUserDoc(false);
} }
} }
async function handleOnboardingStart() { async function handleOnboardingStart() {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:162',message:'handleOnboardingStart: entry',data:{hasAuthUser:!!authUser,hasDatabases:!!databases,hasDatabaseId:!!databaseId,hasUsersCollectionId:!!usersCollectionId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
if (!authUser || !databases || !databaseId || !usersCollectionId) { if (!authUser || !databases || !databaseId || !usersCollectionId) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:165',message:'handleOnboardingStart: configuration incomplete',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
setOnboardingError("Fehler: Konfiguration unvollständig"); setOnboardingError("Fehler: Konfiguration unvollständig");
return; return;
} }
@@ -114,23 +224,129 @@ export default function App() {
setOnboardingError(""); setOnboardingError("");
try { try {
// Prüfe zuerst, ob User-Dokument bereits existiert
let userDocExists = false;
try {
await databases.getDocument(databaseId, usersCollectionId, authUser.$id);
userDocExists = true;
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:178',message:'handleOnboardingStart: userDoc exists',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
} catch (e) {
// Dokument existiert nicht - wird erstellt
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:182',message:'handleOnboardingStart: userDoc does not exist, will create',data:{error:e.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
}
if (!userDocExists) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:187',message:'handleOnboardingStart: creating userDoc',data:{userId:authUser.$id},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
await databases.createDocument( await databases.createDocument(
databaseId, databaseId,
usersCollectionId, usersCollectionId,
authUser.$id, // Document-ID = Auth-User-ID authUser.$id, // Document-ID = Auth-User-ID
{ {
user_name: authUser.name || "User" user_name: authUser.name || "User",
user_extension_load: false
} }
); );
// Erfolg: User-Dokument erstellt // Erfolg: User-Dokument erstellt
setHasUserDoc(true); setHasUserDoc(true);
// user_extension_load ist false beim Erstellen (siehe payload)
setUserExtensionLoad(false);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:202',message:'handleOnboardingStart: userDoc created, setting hasUserDoc=true',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
// Prüfe, ob User bereits Accounts hat (nach dem Erstellen des User-Dokuments)
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:207',message:'handleOnboardingStart: checking for accounts after userDoc creation',data:{userId:authUser.$id},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
try {
const existingAccounts = await fetchManagedAccounts(authUser.$id);
const hasAccountsValue = Array.isArray(existingAccounts) && existingAccounts.length > 0;
setHasAccounts(hasAccountsValue);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:212',message:'handleOnboardingStart: accounts check result after creation',data:{hasAccounts:hasAccountsValue,accountsCount:existingAccounts?.length || 0},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
} catch (accountsErr) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:216',message:'handleOnboardingStart: error checking accounts after creation',data:{error:accountsErr.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
setHasAccounts(false);
}
} else {
// Dokument existiert bereits - prüfe user_extension_load
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:206',message:'handleOnboardingStart: userDoc exists, checking extension load',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
const userDoc = await databases.getDocument(databaseId, usersCollectionId, authUser.$id);
const extensionLoad = userDoc?.user_extension_load === true;
setHasUserDoc(true);
setUserExtensionLoad(extensionLoad);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:212',message:'handleOnboardingStart: userDoc retrieved, setting hasUserDoc=true',data:{extensionLoad},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
}
// Prüfe, ob User Accounts hat (nach dem Erstellen/Prüfen des User-Dokuments)
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:217',message:'handleOnboardingStart: checking for accounts',data:{userId:authUser.$id},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
try {
const existingAccounts = await fetchManagedAccounts(authUser.$id);
const hasAccountsValue = Array.isArray(existingAccounts) && existingAccounts.length > 0;
setHasAccounts(hasAccountsValue);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:222',message:'handleOnboardingStart: accounts check result',data:{hasAccounts:hasAccountsValue,accountsCount:existingAccounts?.length || 0},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
} catch (accountsErr) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:226',message:'handleOnboardingStart: error checking accounts',data:{error:accountsErr.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
setHasAccounts(false);
}
setOnboardingError(""); setOnboardingError("");
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:232',message:'handleOnboardingStart: success, showGate will be',data:{hasUserDoc:true,userExtensionLoad,hasAccounts,showGateWillBe:(hasUserDoc && userExtensionLoad === false && !hasAccounts)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
} catch (e) { } catch (e) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:220',message:'handleOnboardingStart: error caught',data:{errorCode:e.code,errorType:e.type,errorMessage:e.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
// 409 Conflict bedeutet, dass das Dokument bereits existiert // 409 Conflict bedeutet, dass das Dokument bereits existiert
// Das ist ok, da wir idempotent sein wollen // Das ist ok, da wir idempotent sein wollen
if (e.code === 409 || e.type === 'document_already_exists') { if (e.code === 409 || e.type === 'document_already_exists') {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:225',message:'handleOnboardingStart: document already exists (409)',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
setHasUserDoc(true); setHasUserDoc(true);
// Prüfe user_extension_load für existierendes Dokument
if (authUser && databases && databaseId && usersCollectionId) {
try {
const userDoc = await databases.getDocument(databaseId, usersCollectionId, authUser.$id);
const extensionLoad = userDoc?.user_extension_load === true;
setUserExtensionLoad(extensionLoad);
// Prüfe auch Accounts
try {
const existingAccounts = await fetchManagedAccounts(authUser.$id);
const hasAccountsValue = Array.isArray(existingAccounts) && existingAccounts.length > 0;
setHasAccounts(hasAccountsValue);
} catch (accountsErr) {
setHasAccounts(false);
}
} catch (docErr) {
setUserExtensionLoad(null);
setHasAccounts(false);
}
} else {
setUserExtensionLoad(null);
setHasAccounts(false);
}
setOnboardingError(""); setOnboardingError("");
} else if (e.code === 401 || e.type === 'general_unauthorized_scope') { } else if (e.code === 401 || e.type === 'general_unauthorized_scope') {
// 401 Unauthorized: Permissions nicht richtig gesetzt // 401 Unauthorized: Permissions nicht richtig gesetzt
@@ -143,6 +359,9 @@ export default function App() {
setOnboardingError(e.message || "Fehler beim Erstellen des Profils. Bitte versuche es erneut."); setOnboardingError(e.message || "Fehler beim Erstellen des Profils. Bitte versuche es erneut.");
} }
} finally { } finally {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.jsx:245',message:'handleOnboardingStart: finally, setting loading=false',data:{hasUserDoc,userExtensionLoad,showGateWillBe:(!hasUserDoc || (hasUserDoc && userExtensionLoad === false)) && !checkingUserDoc && authUser !== null},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
setOnboardingLoading(false); setOnboardingLoading(false);
} }
} }
@@ -155,6 +374,8 @@ export default function App() {
setStatus({ loading: false, authed: false, error: "" }); setStatus({ loading: false, authed: false, error: "" });
setAuthUser(null); setAuthUser(null);
setHasUserDoc(false); setHasUserDoc(false);
setUserExtensionLoad(null);
setHasAccounts(false);
setOnboardingError(""); setOnboardingError("");
// Extension informieren: Token weg // Extension informieren: Token weg
@@ -196,23 +417,61 @@ export default function App() {
navigate("/"); navigate("/");
}, },
}, },
{
label: "Analysis",
href: "#/analysis",
icon: (
<IconChartLine className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
onClick: (e) => {
e.preventDefault();
navigate("/analysis");
},
},
{ {
label: "Accounts", label: "Accounts",
href: "#/accounts", href: "#/accounts",
icon: ( icon: (
<IconShoppingBag className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" /> <IconUserBolt className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
), ),
disabled: scanning,
onClick: (e) => { onClick: (e) => {
e.preventDefault(); e.preventDefault();
navigate("/accounts"); navigate("/accounts");
}, },
}, },
{ {
label: "Profile", label: "Items",
href: "#", href: "#/items",
icon: ( icon: (
<IconUserBolt className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" /> <IconShoppingBag className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
), ),
onClick: (e) => {
e.preventDefault();
navigate("/items");
},
},
{
label: "Blacklist",
href: "#/blacklist",
icon: (
<IconBan className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
onClick: (e) => {
e.preventDefault();
navigate("/blacklist");
},
},
{
label: "KI",
href: "#/ki",
icon: (
<IconRobot className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
onClick: (e) => {
e.preventDefault();
navigate("/ki");
},
}, },
{ {
label: "Settings", label: "Settings",
@@ -221,17 +480,6 @@ export default function App() {
<IconSettings className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" /> <IconSettings className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
), ),
}, },
{
label: "Logout",
href: "#",
icon: (
<IconArrowLeft className="h-5 w-5 shrink-0 text-neutral-700 dark:text-neutral-200" />
),
onClick: (e) => {
e.preventDefault();
logout();
},
},
]; ];
// Rendere Content basierend auf Route // Rendere Content basierend auf Route
@@ -239,6 +487,26 @@ export default function App() {
if (route === "/accounts") { if (route === "/accounts") {
return <AccountsPage />; return <AccountsPage />;
} }
if (route === "/items") {
return <ItemsPage />;
}
if (route === "/blacklist") {
return <BlacklistPage />;
}
if (route === "/analysis") {
return <AnalysisPage />;
}
if (route === "/ki") {
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-center">
<IconRobot className="h-16 w-16 mx-auto mb-4 text-neutral-700 dark:text-neutral-200" />
<h1 className="text-2xl font-bold text-neutral-800 dark:text-neutral-100 mb-2">KI</h1>
<p className="text-neutral-600 dark:text-neutral-400">KI-Seite wird hier angezeigt</p>
</div>
</div>
);
}
// Default: Dashboard // Default: Dashboard
return <Dashboard />; return <Dashboard />;
}; };
@@ -276,6 +544,12 @@ export default function App() {
<div style={styles.hint}> <div style={styles.hint}>
Nach Login wird der Sperrbildschirm entfernt und die Extension erhaelt ein JWT. Nach Login wird der Sperrbildschirm entfernt und die Extension erhaelt ein JWT.
</div> </div>
{typeof window !== "undefined" &&
!/^https?:\/\/(localhost|127\.0\.0\.1)(\d*)(\/|$)/i.test(window.location.origin) && (
<div style={{ ...styles.hint, marginTop: 8, opacity: 0.9 }}>
CORS: Wenn Login/Verbindung fehlschlaegt, in Appwrite unter Auth Platforms die aktuelle URL ({window.location.origin}) eintragen.
</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -288,6 +562,7 @@ export default function App() {
onStart={handleOnboardingStart} onStart={handleOnboardingStart}
loading={onboardingLoading} loading={onboardingLoading}
error={onboardingError} error={onboardingError}
initialPhase={userExtensionLoad === false ? "extension" : "welcome"}
/> />
)} )}
<div style={{ display: showGate ? "none" : "block" }}> <div style={{ display: showGate ? "none" : "block" }}>
@@ -297,6 +572,7 @@ export default function App() {
"flex w-full flex-1 flex-col overflow-hidden rounded-md border border-neutral-200 bg-gray-100 md:flex-row dark:border-neutral-700 dark:bg-neutral-800", "flex w-full flex-1 flex-col overflow-hidden rounded-md border border-neutral-200 bg-gray-100 md:flex-row dark:border-neutral-700 dark:bg-neutral-800",
"h-screen relative z-10" "h-screen relative z-10"
)}> )}>
{!scanning && (
<Sidebar open={sidebarOpen} setOpen={setSidebarOpen} animate={true}> <Sidebar open={sidebarOpen} setOpen={setSidebarOpen} animate={true}>
<SidebarBody className="justify-between gap-10"> <SidebarBody className="justify-between gap-10">
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto"> <div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
@@ -315,8 +591,12 @@ export default function App() {
</div> </div>
</SidebarBody> </SidebarBody>
</Sidebar> </Sidebar>
)}
{renderContent()} {renderContent()}
</div> </div>
{scanning && (
<ScanningLoader percent={scanProgress?.percent ?? 0} />
)}
</div> </div>
</> </>
)} )}

View File

@@ -0,0 +1,129 @@
"use client";
import React from "react";
import { useScrollSnap } from "./dashboard/hooks/useScrollSnap";
import { cn } from "../lib/utils";
const SECTION_IDS = ["s1", "s2", "s3", "s4"];
const SECTIONS = [
{ id: "s1", label: "Overview" },
{ id: "s2", label: "Accounts" },
{ id: "s3", label: "Products" },
{ id: "s4", label: "Page 4" },
];
function DummySection({ sectionId, title, pageTitle, onJumpToSection, activeSection }) {
return (
<section
id={sectionId}
className="flex min-h-screen w-auto flex-col gap-[18px] rounded-2xl px-4 py-4"
style={{
scrollSnapAlign: "start",
scrollSnapStop: "normal",
color: "var(--text)",
background: "transparent",
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-baseline gap-2.5">
<h1 className="m-0 text-lg font-medium tracking-wide text-[var(--text)]">{pageTitle}</h1>
<span className="rounded-full border border-[var(--line)] bg-white/3 px-2.5 py-1.5 text-xs text-[var(--muted)]">
snap page
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
{SECTIONS.map(({ id, label }) => (
<button
key={id}
onClick={() => onJumpToSection(id)}
className={cn(
"rounded-xl px-3 py-2.5 text-xs transition-all active:translate-y-[1px]",
activeSection === id
? "border border-[rgba(106,166,255,0.7)] bg-[rgba(106,166,255,0.12)] text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
: "border border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]"
)}
>
{label}
</button>
))}
</div>
</div>
<div className="flex items-end justify-between gap-4">
<div>
<h2 className="m-0 text-xl tracking-wide text-[var(--text)]">{title}</h2>
<p className="mt-1.5 mb-0 text-xs text-[var(--muted)]">
Dummy section smooth scroll categories.
</p>
</div>
</div>
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-6 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background:
"radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<p className="text-sm text-[var(--muted)]">
This is <strong className="text-[var(--text)]">{title}</strong>. Use the buttons above to jump between Overview, Accounts, Products, and Page 4.
</p>
</div>
</div>
</section>
);
}
export function ScrollSnapDummyPage({ pageTitle }) {
const { scrollToSection, activeSection } = useScrollSnap(SECTION_IDS);
const handleJumpToSection = (sectionId) => {
scrollToSection(sectionId);
};
return (
<div className="flex flex-1">
<div className="flex h-full w-full flex-1 flex-col gap-2 rounded-2xl border border-neutral-200 bg-white p-2 md:p-4 dark:border-neutral-700 dark:bg-neutral-900">
<div
className="h-full w-full overflow-y-scroll hide-scrollbar"
style={{
scrollSnapType: "y mandatory",
scrollBehavior: "smooth",
}}
>
<div className="min-h-screen">
<DummySection
sectionId="s1"
title="Overview"
pageTitle={pageTitle}
onJumpToSection={handleJumpToSection}
activeSection={activeSection}
/>
<DummySection
sectionId="s2"
title="Accounts"
pageTitle={pageTitle}
onJumpToSection={handleJumpToSection}
activeSection={activeSection}
/>
<DummySection
sectionId="s3"
title="Products"
pageTitle={pageTitle}
onJumpToSection={handleJumpToSection}
activeSection={activeSection}
/>
<DummySection
sectionId="s4"
title="Page 4"
pageTitle={pageTitle}
onJumpToSection={handleJumpToSection}
activeSection={activeSection}
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,10 +5,8 @@ export const useScrollSnap = (sectionIds) => {
const containerRef = useRef(null); const containerRef = useRef(null);
useEffect(() => { useEffect(() => {
const container = containerRef.current;
if (!container) return;
const sections = sectionIds.map(id => document.getElementById(id)).filter(Boolean); const sections = sectionIds.map(id => document.getElementById(id)).filter(Boolean);
if (sections.length === 0) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {

View File

@@ -161,17 +161,20 @@ export const OverviewSection = ({ onJumpToSection, activeAccountId }) => {
}} }}
/> />
<div className="relative"> <div className="relative">
<p className="mb-2 text-xs text-[var(--muted)]">Product overview</p> <p className="mb-2 text-xs text-[var(--muted)]">News</p>
{loading ? (
<div className="mb-3 text-xs text-[var(--muted)]">Loading...</div>
) : (
<div className="mb-3 text-xs text-[var(--muted)]"> <div className="mb-3 text-xs text-[var(--muted)]">
{loading ? "Loading..." : kpis ? `${kpis.totalProducts} total products` : "No data"} <ul className="list-none space-y-2 p-0 m-0">
<li className="text-[var(--muted)]">- System update available</li>
<li className="text-[var(--muted)]">- New features released</li>
<li className="text-[var(--muted)]">- Dashboard improvements</li>
</ul>
</div> </div>
)}
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3"> <div className="flex items-center justify-between border-t border-[var(--line)] pt-3">
<button <div className="text-xs text-[var(--muted)]">Latest updates</div>
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]"
>
Explore products
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -206,6 +209,31 @@ export const OverviewSection = ({ onJumpToSection, activeAccountId }) => {
</div> </div>
</div> </div>
</div> </div>
{activeAccountId && !loading && !error && kpis && (
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background: "radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<p className="mb-2 text-xs text-[var(--muted)]">Product overview</p>
<div className="mb-3 text-xs text-[var(--muted)]">
{loading ? "Loading..." : kpis ? `${kpis.totalProducts} total products` : "No data"}
</div>
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3">
<button
onClick={() => onJumpToSection("s3")}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]"
>
Explore products
</button>
</div>
</div>
</div>
)}
</section> </section>
); );
}; };

View File

@@ -1,12 +1,16 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { DataTable } from "../ui/DataTable"; import { DataTable } from "../ui/DataTable";
import { Pagination } from "../ui/Pagination"; import { Pagination } from "../ui/Pagination";
import { Filters } from "../ui/Filters"; import { Filters } from "../ui/Filters";
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
import { getProductsPage, getProductPreview } from "../../../services/dashboardService"; import { getProductsPage, getProductPreview } from "../../../services/dashboardService";
import { scanProductsForAccount } from "../../../services/productsService"; import { scanProductsForAccount } from "../../../services/productsService";
import { getScanProgress } from "../../../services/ebayParserService";
import { useScan } from "../../../context/ScanContext";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
const SCAN_POLL_MS = 300;
export const ProductsSection = ({ onJumpToSection, activeAccountId }) => { export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -18,8 +22,9 @@ export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [selectedProduct, setSelectedProduct] = useState(null); const [selectedProduct, setSelectedProduct] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanToast, setScanToast] = useState({ show: false, message: "", type: "success" }); const [scanToast, setScanToast] = useState({ show: false, message: "", type: "success" });
const pollRef = useRef(null);
const { scanning, startScan, endScan, updateProgress } = useScan();
// Lade Products wenn activeAccountId oder Filter sich ändern // Lade Products wenn activeAccountId oder Filter sich ändern
useEffect(() => { useEffect(() => {
@@ -121,17 +126,39 @@ export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
return; return;
} }
setScanning(true);
setError(null); setError(null);
startScan();
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
pollRef.current = setInterval(async () => {
try {
const p = await getScanProgress();
if (p && (p.percent != null || p.phase || p.complete)) {
updateProgress({
percent: p.percent ?? 0,
phase: p.phase ?? "idle",
total: p.total ?? 0,
current: p.current ?? 0,
complete: !!p.complete,
});
}
} catch {
// ignore
}
}, SCAN_POLL_MS);
try { try {
// Führe Scan aus
const result = await scanProductsForAccount(activeAccountId); const result = await scanProductsForAccount(activeAccountId);
stopPolling();
// Refresh Products-Liste
await loadProducts(); await loadProducts();
// Zeige Erfolgs-Toast
const updated = result.updated ?? 0; const updated = result.updated ?? 0;
setScanToast({ setScanToast({
show: true, show: true,
@@ -142,22 +169,44 @@ export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
setScanToast({ show: false, message: "", type: "success" }); setScanToast({ show: false, message: "", type: "success" });
}, 3000); }, 3000);
} catch (e) { } catch (e) {
stopPolling();
console.error("Fehler beim Scannen der Produkte:", e); console.error("Fehler beim Scannen der Produkte:", e);
// Logge meta für Dev-Debugging
const meta = e.meta || {}; const meta = e.meta || {};
if (Object.keys(meta).length > 0) { if (Object.keys(meta).length > 0) {
console.log("[scan meta]", meta); console.log("[scan meta]", meta);
} }
// Prüfe auf no_items_found oder empty_items
const errorMsg = e.message || "Fehler beim Scannen der Produkte"; const errorMsg = e.message || "Fehler beim Scannen der Produkte";
const isNoItems = errorMsg.includes("no_items_found") || errorMsg.includes("empty_items"); const isNoItems = errorMsg.includes("no_items_found") || errorMsg.includes("empty_items");
const isParsingFailed = errorMsg.includes("Parsing failed");
// Zeige Toast mit spezifischer Meldung für 0 Items let toastMessage = errorMsg;
const toastMessage = isNoItems if (isNoItems) {
? "0 Produkte gefunden. Bitte pruefe, ob die URL auf den Shop/Artikel-Bereich des Sellers zeigt." toastMessage = "0 Produkte gefunden. Bitte prüfe, ob die URL auf den Shop/Artikel-Bereich des Sellers zeigt.";
: errorMsg; } else if (isParsingFailed) {
toastMessage = "Extension konnte keine Produkte finden. ";
if (meta.pageType && meta.pageType !== "unknown") {
toastMessage += `Seitentyp: ${meta.pageType}. `;
}
if (meta.reason) {
toastMessage += `Grund: ${meta.reason}. `;
}
if (meta.finalUrl) {
toastMessage += `URL: ${meta.finalUrl}`;
}
if (!meta.pageType && !meta.reason && !meta.finalUrl) {
toastMessage += "Bitte stelle sicher, dass die Account-URL auf eine Seite mit Produkt-Listings zeigt (z.B. Storefront oder Seller-Listings).";
}
} else if (e.meta) {
const m = e.meta;
if (m.pageType && m.pageType !== "unknown") {
toastMessage += ` (Seitentyp: ${m.pageType})`;
}
if (m.reason) {
toastMessage += ` (${m.reason})`;
}
}
setScanToast({ setScanToast({
show: true, show: true,
@@ -166,9 +215,13 @@ export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
}); });
setTimeout(() => { setTimeout(() => {
setScanToast({ show: false, message: "", type: "success" }); setScanToast({ show: false, message: "", type: "success" });
}, 3000); }, 5000);
} finally { } finally {
setScanning(false); if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
endScan();
} }
}; };
@@ -275,11 +328,15 @@ export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
<DataTable <DataTable
columns={["Title", "Price", "Status", "Category", "Action"]} columns={["Title", "Price", "Status", "Category", "Action"]}
data={products} data={products}
onRowClick={(row) => handleOpenProduct(row.$id)}
renderCell={(col, row) => { renderCell={(col, row) => {
if (col === "Action") { if (col === "Action") {
return ( return (
<button <button
onClick={() => handleOpenProduct(row.$id)} onClick={(e) => {
e.stopPropagation();
handleOpenProduct(row.$id);
}}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]" className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]"
> >
Open Open
@@ -326,34 +383,130 @@ export const ProductsSection = ({ onJumpToSection, activeAccountId }) => {
{previewLoading ? ( {previewLoading ? (
<div className="mb-4 text-xs text-[var(--muted)]">Loading preview...</div> <div className="mb-4 text-xs text-[var(--muted)]">Loading preview...</div>
) : selectedProduct ? ( ) : selectedProduct ? (
<div className="mb-4 space-y-2 text-xs text-[var(--muted)]"> <div className="mb-4 space-y-3 text-xs text-[var(--muted)]">
<div> <div>
<strong>Title:</strong> {selectedProduct.product_title || selectedProduct.$id} <strong className="text-[var(--text)]">Title:</strong>{" "}
<span className="text-[var(--text)]">
{selectedProduct.product_title || selectedProduct.$id}
</span>
</div> </div>
{selectedProduct.product_price && (
<div> <div>
<strong>Price:</strong> EUR {selectedProduct.product_price} <strong className="text-[var(--text)]">Price:</strong>{" "}
<span className="text-[var(--text)]">
{selectedProduct.product_price
? `${selectedProduct.product_currency || "EUR"} ${selectedProduct.product_price}`
: "N/A"}
</span>
</div>
<div>
<strong className="text-[var(--text)]">Status:</strong>{" "}
<span className="text-[var(--text)]">
{selectedProduct.product_status || "unknown"}
</span>
</div>
<div>
<strong className="text-[var(--text)]">Category:</strong>{" "}
<span className="text-[var(--text)]">
{selectedProduct.product_category || "-"}
</span>
</div>
<div>
<strong className="text-[var(--text)]">Condition:</strong>{" "}
<span className="text-[var(--text)]">
{selectedProduct.product_condition || "-"}
</span>
</div>
{selectedProduct.product_url && (
<div>
<strong className="text-[var(--text)]">URL:</strong>{" "}
<a
href={selectedProduct.product_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline dark:text-blue-400"
>
{selectedProduct.product_url.length > 50
? `${selectedProduct.product_url.substring(0, 50)}...`
: selectedProduct.product_url}
</a>
</div> </div>
)} )}
{selectedProduct.product_status && (
<div> <div>
<strong>Status:</strong> {selectedProduct.product_status} <strong className="text-[var(--text)]">Platform:</strong>{" "}
<span className="text-[var(--text)]">
{selectedProduct.product_platform || "-"}
</span>
{selectedProduct.product_platform_market && (
<span className="text-[var(--text)]">
{" "}
({selectedProduct.product_platform_market})
</span>
)}
</div>
{selectedProduct.product_platform_product_id && (
<div>
<strong className="text-[var(--text)]">Platform Product ID:</strong>{" "}
<span className="text-[var(--text)]">
{selectedProduct.product_platform_product_id}
</span>
</div> </div>
)} )}
{selectedProduct.product_category && ( {(selectedProduct.product_quantity_available != null ||
selectedProduct.product_quantity_sold != null) && (
<div className="mt-2 border-t border-[var(--line)] pt-2">
<strong className="text-[var(--text)]">Quantity:</strong>
{selectedProduct.product_quantity_available != null && (
<div className="ml-4 text-[var(--text)]">
Available: {selectedProduct.product_quantity_available}
</div>
)}
{selectedProduct.product_quantity_sold != null && (
<div className="ml-4 text-[var(--text)]">
Sold: {selectedProduct.product_quantity_sold}
</div>
)}
</div>
)}
{(selectedProduct.product_watch_count != null ||
selectedProduct.product_in_carts_count != null) && (
<div className="border-t border-[var(--line)] pt-2">
<strong className="text-[var(--text)]">Engagement:</strong>
{selectedProduct.product_watch_count != null && (
<div className="ml-4 text-[var(--text)]">
Watches: {selectedProduct.product_watch_count}
</div>
)}
{selectedProduct.product_in_carts_count != null && (
<div className="ml-4 text-[var(--text)]">
In Carts: {selectedProduct.product_in_carts_count}
</div>
)}
</div>
)}
{selectedProduct.product_last_seen_at && (
<div> <div>
<strong>Category:</strong> {selectedProduct.product_category} <strong className="text-[var(--text)]">Last Seen:</strong>{" "}
<span className="text-[var(--text)]">
{new Date(selectedProduct.product_last_seen_at).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div> </div>
)} )}
{selectedProduct.details && ( {selectedProduct.details && (
<div className="mt-3 border-t border-[var(--line)] pt-2"> <div className="mt-3 border-t border-[var(--line)] pt-2">
<strong>Details available</strong> <strong className="text-[var(--text)]">Additional Details:</strong>
<div className="mt-1 text-[var(--text)]">Available</div>
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="mb-4 text-xs text-[var(--muted)]"> <div className="mb-4 text-xs text-[var(--muted)]">
Click "Open" on a product to preview details. Click on a product row or "Open" button to preview details.
</div> </div>
)} )}
<div className="flex items-center justify-between border-t border-[var(--line)] pt-3"> <div className="flex items-center justify-between border-t border-[var(--line)] pt-3">

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
export const DataTable = ({ columns, data, renderCell }) => { export const DataTable = ({ columns, data, renderCell, onRowClick }) => {
return ( return (
<div className="overflow-hidden rounded-[18px]"> <div className="overflow-hidden rounded-[18px]">
<table className="w-full border-separate border-spacing-0"> <table className="w-full border-separate border-spacing-0">
@@ -18,7 +18,11 @@ export const DataTable = ({ columns, data, renderCell }) => {
</thead> </thead>
<tbody> <tbody>
{data.map((row, rowIdx) => ( {data.map((row, rowIdx) => (
<tr key={rowIdx}> <tr
key={rowIdx}
onClick={() => onRowClick && onRowClick(row, rowIdx)}
className={onRowClick ? "cursor-pointer transition-colors hover:bg-white/5" : ""}
>
{columns.map((col, colIdx) => ( {columns.map((col, colIdx) => (
<td <td
key={colIdx} key={colIdx}

File diff suppressed because it is too large Load Diff

View File

@@ -131,11 +131,24 @@ export const SidebarLink = ({
...props ...props
}) => { }) => {
const { open, animate } = useSidebar(); const { open, animate } = useSidebar();
const disabled = !!link.disabled;
const handleClick = (e) => {
if (disabled) {
e.preventDefault();
return;
}
link.onClick?.(e);
};
return ( return (
<a <a
href={link.href} href={link.href}
onClick={link.onClick} onClick={handleClick}
className={cn("flex items-center justify-start gap-2 group/sidebar py-2", className)} className={cn(
"flex items-center justify-start gap-2 group/sidebar py-2",
disabled && "pointer-events-none opacity-50 cursor-not-allowed",
className
)}
aria-disabled={disabled}
{...props}> {...props}>
{link.icon} {link.icon}
<motion.span <motion.span

View File

@@ -0,0 +1,51 @@
.animated-gradient-text {
position: relative;
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
border-radius: 1.25rem;
font-weight: 500;
backdrop-filter: blur(10px);
transition: box-shadow 0.5s ease-out;
overflow: hidden;
}
.animated-gradient-text.with-border {
padding: 0.35rem 0.75rem;
}
.gradient-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
z-index: 0;
pointer-events: none;
}
.gradient-overlay::before {
content: '';
position: absolute;
left: 0;
top: 0;
border-radius: inherit;
width: calc(100% - 2px);
height: calc(100% - 2px);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background-color: #060010;
z-index: -1;
}
.text-content {
display: inline-block;
position: relative;
z-index: 2;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}

View File

@@ -0,0 +1,100 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
import './GradientText.css';
export default function GradientText({
children,
className = '',
colors = ['#5227FF', '#FF9FFC', '#B19EEF'],
animationSpeed = 8,
showBorder = false,
direction = 'horizontal',
pauseOnHover = false,
yoyo = true,
style = {}
}) {
const [isPaused, setIsPaused] = useState(false);
const progress = useMotionValue(0);
const elapsedRef = useRef(0);
const lastTimeRef = useRef(null);
const animationDuration = animationSpeed * 1000;
useAnimationFrame(time => {
if (isPaused) {
lastTimeRef.current = null;
return;
}
if (lastTimeRef.current === null) {
lastTimeRef.current = time;
return;
}
const deltaTime = time - lastTimeRef.current;
lastTimeRef.current = time;
elapsedRef.current += deltaTime;
if (yoyo) {
const fullCycle = animationDuration * 2;
const cycleTime = elapsedRef.current % fullCycle;
if (cycleTime < animationDuration) {
progress.set((cycleTime / animationDuration) * 100);
} else {
progress.set(100 - ((cycleTime - animationDuration) / animationDuration) * 100);
}
} else {
// Continuously increase position for seamless looping
progress.set((elapsedRef.current / animationDuration) * 100);
}
});
useEffect(() => {
elapsedRef.current = 0;
progress.set(0);
}, [animationSpeed, progress, yoyo]);
const backgroundPosition = useTransform(progress, p => {
if (direction === 'horizontal') {
return `${p}% 50%`;
} else if (direction === 'vertical') {
return `50% ${p}%`;
} else {
// For diagonal, move only horizontally to avoid interference patterns
return `${p}% 50%`;
}
});
const handleMouseEnter = useCallback(() => {
if (pauseOnHover) setIsPaused(true);
}, [pauseOnHover]);
const handleMouseLeave = useCallback(() => {
if (pauseOnHover) setIsPaused(false);
}, [pauseOnHover]);
const gradientAngle =
direction === 'horizontal' ? 'to right' : direction === 'vertical' ? 'to bottom' : 'to bottom right';
// Duplicate first color at the end for seamless looping
const gradientColors = [...colors, colors[0]].join(', ');
const gradientStyle = {
backgroundImage: `linear-gradient(${gradientAngle}, ${gradientColors})`,
backgroundSize: direction === 'horizontal' ? '300% 100%' : direction === 'vertical' ? '100% 300%' : '300% 300%',
backgroundRepeat: 'repeat'
};
return (
<motion.div
className={`animated-gradient-text ${showBorder ? 'with-border' : ''} ${className}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showBorder && <motion.div className="gradient-overlay" style={{ ...gradientStyle, backgroundPosition }} />}
<motion.div className="text-content" style={{ ...gradientStyle, backgroundPosition, ...style }}>
{children}
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,113 @@
import React from "react";
import styled from "styled-components";
const StyledWrapper = styled.div`
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
pointer-events: none;
.loader-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 180px;
height: 180px;
font-family: "Inter", sans-serif;
font-size: 1.2em;
font-weight: 300;
color: white;
border-radius: 50%;
background-color: transparent;
user-select: none;
}
.loader {
position: absolute;
top: 0;
left: 0;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 50%;
background-color: transparent;
animation: loader-rotate 2s linear infinite;
z-index: 0;
}
@keyframes loader-rotate {
0% {
transform: rotate(90deg);
box-shadow:
0 10px 20px 0 #fff inset,
0 20px 30px 0 #ad5fff inset,
0 60px 60px 0 #471eec inset;
}
50% {
transform: rotate(270deg);
box-shadow:
0 10px 20px 0 #fff inset,
0 20px 10px 0 #d60a47 inset,
0 40px 60px 0 #311e80 inset;
}
100% {
transform: rotate(450deg);
box-shadow:
0 10px 20px 0 #fff inset,
0 20px 30px 0 #ad5fff inset,
0 60px 60px 0 #471eec inset;
}
}
.loader-letter {
display: inline-block;
opacity: 0.4;
transform: translateY(0);
animation: loader-letter-anim 2s infinite;
z-index: 1;
border-radius: 50ch;
border: none;
}
.loader-letter:nth-child(1) { animation-delay: 0s; }
.loader-letter:nth-child(2) { animation-delay: 0.1s; }
.loader-letter:nth-child(3) { animation-delay: 0.2s; }
.loader-letter:nth-child(4) { animation-delay: 0.3s; }
.loader-letter:nth-child(5) { animation-delay: 0.4s; }
.loader-letter:nth-child(6) { animation-delay: 0.5s; }
.loader-letter:nth-child(7) { animation-delay: 0.6s; }
.loader-letter:nth-child(8) { animation-delay: 0.7s; }
.loader-letter:nth-child(9) { animation-delay: 0.8s; }
.loader-letter:nth-child(10) { animation-delay: 0.9s; }
@keyframes loader-letter-anim {
0%, 100% { opacity: 0.4; transform: translateY(0); }
20% { opacity: 1; transform: scale(1.15); }
40% { opacity: 0.7; transform: translateY(0); }
}
.loader-percent {
margin-left: 2px;
opacity: 0.9;
z-index: 1;
}
`;
const LETTERS = "Scanning".split("");
export default function ScanningLoader({ percent = 0 }) {
return (
<StyledWrapper>
<div className="loader-wrapper">
{LETTERS.map((char, i) => (
<span key={i} className="loader-letter">
{char}
</span>
))}
<span className="loader-percent">{Math.round(percent)}%</span>
<div className="loader" />
</div>
</StyledWrapper>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { cn } from "../../lib/utils";
export const BentoGrid = ({ className, children }) => {
return (
<div
className={cn(
"mx-auto grid max-w-7xl grid-cols-1 gap-4 md:auto-rows-[18rem] md:grid-cols-3",
className
)}
>
{children}
</div>
);
};
export const BentoGridItem = ({
className,
title,
description,
header,
icon,
}) => {
return (
<div
className={cn(
"group/bento row-span-1 flex flex-col justify-between space-y-4 rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none",
className
)}
>
{header}
<div className="transition duration-200 group-hover/bento:translate-x-2">
{icon}
<div className="mt-2 mb-2 font-sans font-bold text-neutral-600 dark:text-neutral-200">
{title}
</div>
<div className="font-sans text-xs font-normal text-neutral-600 dark:text-neutral-300">
{description}
</div>
</div>
</div>
);
};

View File

@@ -2,8 +2,8 @@
import React from "react"; import React from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
export default function ColourfulText({ text }) { export default function ColourfulText({ text, usePurpleTheme = false }) {
const colors = [ const defaultColors = [
"rgb(131, 179, 32)", "rgb(131, 179, 32)",
"rgb(47, 195, 106)", "rgb(47, 195, 106)",
"rgb(42, 169, 210)", "rgb(42, 169, 210)",
@@ -16,6 +16,21 @@ export default function ColourfulText({ text }) {
"rgb(249, 129, 47)", "rgb(249, 129, 47)",
]; ];
const purpleColors = [
"rgb(174, 182, 192)",
"rgb(217, 141, 233)",
"rgb(212, 170, 235)",
"rgb(230, 170, 239)",
"rgb(231, 170, 240)",
"rgb(231, 170, 240)",
"rgb(231, 170, 240)",
"rgb(231, 170, 240)",
"rgb(231, 170, 240)",
"rgb(231, 170, 240)",
];
const colors = usePurpleTheme ? purpleColors : defaultColors;
const [currentColors, setCurrentColors] = React.useState(colors); const [currentColors, setCurrentColors] = React.useState(colors);
const [count, setCount] = React.useState(0); const [count, setCount] = React.useState(0);

View File

@@ -0,0 +1,260 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
/**
* Canvas-based dotted background that randomly glows and dims.
* - Uses a stable grid of dots.
* - Each dot gets its own phase + speed producing organic shimmering.
* - Handles high-DPI and resizes via ResizeObserver.
*/
export const DottedGlowBackground = ({
className,
gap = 12,
radius = 2,
color = "rgba(0,0,0,0.7)",
darkColor,
glowColor = "rgba(0, 170, 255, 0.85)",
darkGlowColor,
colorLightVar,
colorDarkVar,
glowColorLightVar,
glowColorDarkVar,
opacity = 0.6,
backgroundOpacity = 0,
speedMin = 0.4,
speedMax = 1.3,
speedScale = 1
}) => {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const [resolvedColor, setResolvedColor] = useState(color);
const [resolvedGlowColor, setResolvedGlowColor] = useState(glowColor);
// Resolve CSS variable value from the container or root
const resolveCssVariable = (el, variableName) => {
if (!variableName) return null;
const normalized = variableName.startsWith("--")
? variableName
: `--${variableName}`;
const fromEl = getComputedStyle(el)
.getPropertyValue(normalized)
.trim();
if (fromEl) return fromEl;
const root = document.documentElement;
const fromRoot = getComputedStyle(root).getPropertyValue(normalized).trim();
return fromRoot || null;
};
const detectDarkMode = () => {
const root = document.documentElement;
if (root.classList.contains("dark")) return true;
if (root.classList.contains("light")) return false;
return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
};
// Keep resolved colors in sync with theme changes and prop updates
useEffect(() => {
const container = containerRef.current ?? document.documentElement;
const compute = () => {
const isDark = detectDarkMode();
let nextColor = color;
let nextGlow = glowColor;
if (isDark) {
const varDot = resolveCssVariable(container, colorDarkVar);
const varGlow = resolveCssVariable(container, glowColorDarkVar);
nextColor = varDot || darkColor || nextColor;
nextGlow = varGlow || darkGlowColor || nextGlow;
} else {
const varDot = resolveCssVariable(container, colorLightVar);
const varGlow = resolveCssVariable(container, glowColorLightVar);
nextColor = varDot || nextColor;
nextGlow = varGlow || nextGlow;
}
setResolvedColor(nextColor);
setResolvedGlowColor(nextGlow);
};
compute();
const mql = window.matchMedia
? window.matchMedia("(prefers-color-scheme: dark)")
: null;
const handleMql = () => compute();
mql?.addEventListener?.("change", handleMql);
const mo = new MutationObserver(() => compute());
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
});
return () => {
mql?.removeEventListener?.("change", handleMql);
mo.disconnect();
};
}, [
color,
darkColor,
glowColor,
darkGlowColor,
colorLightVar,
colorDarkVar,
glowColorLightVar,
glowColorDarkVar,
]);
useEffect(() => {
const el = canvasRef.current;
const container = containerRef.current;
if (!el || !container) return;
const ctx = el.getContext("2d");
if (!ctx) return;
let raf = 0;
let stopped = false;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const resize = () => {
const { width, height } = container.getBoundingClientRect();
el.width = Math.max(1, Math.floor(width * dpr));
el.height = Math.max(1, Math.floor(height * dpr));
el.style.width = `${Math.floor(width)}px`;
el.style.height = `${Math.floor(height)}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
// Precompute dot metadata for a medium-sized grid and regenerate on resize
let dots = [];
const regenDots = () => {
dots = [];
const { width, height } = container.getBoundingClientRect();
const cols = Math.ceil(width / gap) + 2;
const rows = Math.ceil(height / gap) + 2;
const min = Math.min(speedMin, speedMax);
const max = Math.max(speedMin, speedMax);
for (let i = -1; i < cols; i++) {
for (let j = -1; j < rows; j++) {
const x = i * gap + (j % 2 === 0 ? 0 : gap * 0.5); // offset every other row
const y = j * gap;
// Randomize phase and speed slightly per dot
const phase = Math.random() * Math.PI * 2;
const span = Math.max(max - min, 0);
const speed = min + Math.random() * span; // configurable rad/s
dots.push({ x, y, phase, speed });
}
}
};
const regenThrottled = () => {
regenDots();
};
regenDots();
let last = performance.now();
const draw = (now) => {
if (stopped) return;
const dt = (now - last) / 1000; // seconds
last = now;
const { width, height } = container.getBoundingClientRect();
ctx.clearRect(0, 0, el.width, el.height);
ctx.globalAlpha = opacity;
// optional subtle background fade for depth (defaults to 0 = transparent)
if (backgroundOpacity > 0) {
const grad = ctx.createRadialGradient(
width * 0.5,
height * 0.4,
Math.min(width, height) * 0.1,
width * 0.5,
height * 0.5,
Math.max(width, height) * 0.7
);
grad.addColorStop(0, "rgba(0,0,0,0)");
grad.addColorStop(1, `rgba(0,0,0,${Math.min(Math.max(backgroundOpacity, 0), 1)})`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
}
// animate dots
ctx.save();
ctx.fillStyle = resolvedColor;
const time = (now / 1000) * Math.max(speedScale, 0);
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
// Linear triangle wave 0..1..0 for linear glow/dim
const mod = (time * d.speed + d.phase) % 2;
const lin = mod < 1 ? mod : 2 - mod; // 0..1..0
const a = 0.25 + 0.55 * lin; // 0.25..0.8 linearly
// draw glow when bright
if (a > 0.6) {
const glow = (a - 0.6) / 0.4; // 0..1
ctx.shadowColor = resolvedGlowColor;
ctx.shadowBlur = 6 * glow;
} else {
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
}
ctx.globalAlpha = a * opacity;
ctx.beginPath();
ctx.arc(d.x, d.y, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
raf = requestAnimationFrame(draw);
};
const handleResize = () => {
resize();
regenThrottled();
};
window.addEventListener("resize", handleResize);
raf = requestAnimationFrame(draw);
return () => {
stopped = true;
cancelAnimationFrame(raf);
window.removeEventListener("resize", handleResize);
ro.disconnect();
};
}, [
gap,
radius,
resolvedColor,
resolvedGlowColor,
opacity,
backgroundOpacity,
speedMin,
speedMax,
speedScale,
]);
return (
<div
ref={containerRef}
className={className}
style={{ position: "absolute", inset: 0 }}>
<canvas
ref={canvasRef}
style={{ display: "block", width: "100%", height: "100%" }} />
</div>
);
};

View File

@@ -0,0 +1,51 @@
import React, { createContext, useContext, useState, useCallback } from "react";
const ScanContext = createContext(null);
export function useScan() {
const ctx = useContext(ScanContext);
if (!ctx) {
throw new Error("useScan must be used within ScanProvider");
}
return ctx;
}
/**
* ScanProvider hält scanning + scanProgress für Scan-UI (Loader, Dashboard ausblenden, etc.)
*/
export function ScanProvider({ children }) {
const [scanning, setScanning] = useState(false);
const [scanProgress, setScanProgress] = useState({
percent: 0,
phase: "idle",
total: 0,
current: 0,
complete: false,
});
const startScan = useCallback(() => {
setScanning(true);
setScanProgress({ percent: 0, phase: "listing", total: 0, current: 0, complete: false });
}, []);
const updateProgress = useCallback((data) => {
setScanProgress((prev) => ({ ...prev, ...data }));
}, []);
const endScan = useCallback(() => {
setScanning(false);
setScanProgress({ percent: 0, phase: "idle", total: 0, current: 0, complete: false });
}, []);
const value = {
scanning,
setScanning,
scanProgress,
setScanProgress,
startScan,
updateProgress,
endScan,
};
return <ScanContext.Provider value={value}>{children}</ScanContext.Provider>;
}

View File

@@ -41,3 +41,9 @@
} }
} }
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,6 +1,10 @@
/** /**
* Zentrales Appwrite Client Setup * Zentrales Appwrite Client Setup
* Stellt Client, Account, Databases Instanzen und Helper-Funktionen bereit * Stellt Client, Account, Databases Instanzen und Helper-Funktionen bereit
*
* CORS: Wenn die App unter einer anderen Origin läuft (z. B. https://www.eship.pro),
* muss im Appwrite-Dashboard unter "Settings" → "Platforms" die entsprechende
* Origin (z. B. https://www.eship.pro) hinzugefügt werden, sonst blockiert CORS die Requests.
*/ */
import { Client, Account, Databases } from "appwrite"; import { Client, Account, Databases } from "appwrite";

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
import { ScanProvider } from './context/ScanContext'
import './index.css' import './index.css'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<ScanProvider>
<App /> <App />
</ScanProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,23 +1,458 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { IconPlus, IconChevronDown, IconX, IconRefresh } from "@tabler/icons-react"; import {
IconPlus,
IconX,
IconRefresh,
IconChevronDown,
} from "@tabler/icons-react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useHashRoute } from "../lib/routing"; import { useHashRoute } from "../lib/routing";
import { import {
setActiveAccountId, setActiveAccountId,
getActiveAccountId,
getAccountDisplayName, getAccountDisplayName,
} from "../services/accountService"; } from "../services/accountService";
import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService"; import { fetchManagedAccounts, createManagedAccount, updateManagedAccount, determineRefreshStatus, calculateDataFreshness, getLastSuccessfulAccountMetric } from "../services/accountsService";
import { getAuthUser } from "../lib/appwrite"; import { upsertAccountMetric } from "../services/accountMetricsService";
import { parseEbayAccount } from "../services/ebayParserService"; import { getAuthUser, databases, databaseId } from "../lib/appwrite";
import { DataTable } from "../components/dashboard/ui/DataTable"; import { parseEbayAccount, parseViaExtensionExtended } from "../services/ebayParserService";
import { BentoGrid, BentoGridItem } from "../components/ui/bento-grid";
import GradientText from "../components/ui/GradientText";
function AccountNameCard({
name,
url,
platformAccountId,
accounts,
displayedAccountId,
onSelectAccount,
className,
}) {
const containerRef = React.useRef(null);
const measureRef = React.useRef(null);
const listRef = React.useRef(null);
const [fontSize, setFontSize] = React.useState(48);
const [listOpen, setListOpen] = React.useState(false);
const otherAccounts = React.useMemo(
() => accounts.filter((acc) => (acc.$id || acc.id) !== displayedAccountId),
[accounts, displayedAccountId]
);
React.useEffect(() => {
if (!listOpen) return;
const handleClickOutside = (e) => {
if (listRef.current && !listRef.current.contains(e.target)) setListOpen(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [listOpen]);
React.useEffect(() => {
const cont = containerRef.current;
const meas = measureRef.current;
if (!cont || !meas || !name) return;
const fit = () => {
const w = cont.clientWidth;
if (w <= 0) return;
let fs = 48;
meas.style.fontSize = `${fs}px`;
while (meas.scrollWidth > w && fs > 12) {
fs -= 2;
meas.style.fontSize = `${fs}px`;
}
setFontSize(fs);
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(cont);
return () => ro.disconnect();
}, [name]);
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
<div
ref={containerRef}
className="relative flex w-full shrink-0 flex-col items-start justify-start overflow-hidden text-left"
>
<span
ref={measureRef}
className="pointer-events-none invisible absolute left-0 top-0 whitespace-nowrap font-bold leading-tight text-[var(--text)]"
aria-hidden
>
{name}
</span>
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="max-w-full shrink-0 whitespace-nowrap hover:underline"
>
<GradientText
colors={['#5227FF', '#FF9FFC', '#B19EEF']}
animationSpeed={5}
showBorder={false}
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight"
style={{ fontSize: `${fontSize}px` }}
>
{name}
</GradientText>
</a>
) : (
<GradientText
colors={['#5227FF', '#FF9FFC', '#B19EEF']}
animationSpeed={5}
showBorder={false}
className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight"
style={{ fontSize: `${fontSize}px` }}
>
{name}
</GradientText>
)}
</div>
{platformAccountId != null && platformAccountId !== "" && (
<div className="mt-1 shrink-0 text-sm text-[var(--muted)]">
ID: {platformAccountId}
</div>
)}
<div ref={listRef} className="relative mt-2 flex shrink-0 flex-col gap-1">
<label className="text-xs font-medium text-[var(--muted)]">Account wechseln</label>
<button
type="button"
onClick={() => setListOpen((o) => !o)}
className="flex w-full items-center justify-between rounded-xl border border-[var(--line)] bg-white/5 px-3 py-2 text-left text-sm text-[var(--text)] outline-none transition-colors hover:border-[rgba(106,166,255,0.4)] focus:border-[rgba(106,166,255,0.5)] dark:bg-neutral-800"
>
<span>Account wählen</span>
<IconChevronDown className={cn("h-4 w-4 shrink-0 transition-transform", listOpen && "rotate-180")} />
</button>
{listOpen && (
<ul className="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-xl border border-[var(--line)] bg-white shadow-lg dark:bg-neutral-800">
{otherAccounts.length === 0 ? (
<li className="px-3 py-2 text-xs text-[var(--muted)]">Keine weiteren Accounts</li>
) : (
otherAccounts.map((acc) => {
const id = acc.$id || acc.id;
const label = getAccountDisplayName(acc) || id;
return (
<li key={id}>
<button
type="button"
onClick={() => {
onSelectAccount(id);
setListOpen(false);
}}
className="flex w-full items-center px-3 py-2 text-left text-sm text-[var(--text)] transition-colors hover:bg-white/10 dark:hover:bg-neutral-700"
>
{label}
</button>
</li>
);
})
)}
</ul>
)}
</div>
</div>
);
}
const PLATFORM_LOGOS = {
ebay: {
local: "/assets/platforms/ebay.png",
fallback: "https://upload.wikimedia.org/wikipedia/commons/1/1b/EBay_logo.svg",
},
amazon: {
local: "/assets/platforms/amazon.png",
fallback: "https://upload.wikimedia.org/wikipedia/commons/a/a9/Amazon_logo.svg",
},
};
function PlatformLogoCard({ platform, market, className }) {
const key = (platform || "").toLowerCase();
const cfg = PLATFORM_LOGOS[key];
const [src, setSrc] = React.useState(cfg ? cfg.local : null);
const [usedFallback, setUsedFallback] = React.useState(false);
React.useEffect(() => {
const c = PLATFORM_LOGOS[key];
if (!c) {
setSrc(null);
setUsedFallback(true);
return;
}
setSrc(c.local);
setUsedFallback(false);
}, [key]);
const onError = React.useCallback(() => {
if (usedFallback || !cfg) return;
setSrc(cfg.fallback);
setUsedFallback(true);
}, [cfg, usedFallback]);
return (
<div
className={cn(
"row-span-1 relative flex h-full min-h-[12rem] items-center justify-center overflow-hidden rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
{cfg ? (
<img
src={src || cfg.fallback}
alt=""
onError={onError}
className="max-h-full w-full object-contain"
/>
) : (
<span className="text-xs text-[var(--muted)]">{platform || ""}</span>
)}
{market != null && String(market).trim() !== "" && (
<div className="absolute bottom-2 right-2 font-bold text-[var(--text)]">
{String(market).trim().toUpperCase()}
</div>
)}
</div>
);
}
function RangCard({ rank, className }) {
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col items-center justify-center rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
<div className="text-center">
<div className="text-xs font-medium uppercase tracking-wide text-[var(--muted)]">Rang</div>
<div className="mt-1 text-2xl font-bold text-[var(--text)]">{rank ?? ""}</div>
</div>
</div>
);
}
function AccountRefreshCard({
onRefresh,
isRefreshing,
lastRefreshDate,
streak,
dataFreshness = 'Aging',
className
}) {
const getLastRefreshText = () => {
if (!lastRefreshDate) return "Not refreshed today";
const today = new Date();
const refreshDate = new Date(lastRefreshDate);
const diffTime = today - refreshDate;
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Last refresh: Today";
if (diffDays === 1) return "Last refresh: Yesterday";
if (diffDays < 7) return `Last refresh: ${diffDays} days ago`;
return `Last refresh: ${refreshDate.toLocaleDateString('de-DE')}`;
};
const getFreshnessLabel = () => {
switch (dataFreshness) {
case 'Fresh': return 'Fresh';
case 'Outdated': return 'Outdated';
default: return 'Aging';
}
};
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
{/* Grid: 5 Zeilen x 5 Spalten */}
<div className="grid h-full grid-cols-5 grid-rows-5 gap-0">
{/* Zeile 1: Kontext & Bedeutung */}
{/* Bereich A (Zeile 1, Spalten 1-5) */}
<div className="col-span-5 row-span-1 flex items-center justify-between">
<div className="text-xs font-medium text-[var(--muted)]">Account Refresh</div>
<div className="text-[10px] text-[var(--muted)]">manual</div>
</div>
{/* Zeile 2-3: Zentrale Aktion (Ritualkern) */}
{/* Bereich B (Zeilen 2-3, Spalten 1-5) */}
<div className="col-span-5 row-span-2 flex items-center justify-center">
<button
onClick={() => {
// #region agent log
fetch('http://127.0.0.1:7243/ingest/2cdae91e-9f0b-48c7-8e02-a970375bdaff',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:'debug-session',runId:'run1',hypothesisId:'H1',location:'AccountsPage.jsx:297',message:'AccountRefreshCard click',data:{isRefreshing,hasOnRefresh:!!onRefresh},timestamp:Date.now()})}).catch(()=>{});
// #endregion
if (onRefresh) {
onRefresh();
}
}}
disabled={isRefreshing}
className={cn(
"w-full h-full rounded-xl border transition-all active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed",
"border-[var(--line)] bg-white/3 text-[var(--text)] hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)]",
"flex items-center justify-center gap-2 font-medium text-sm"
)}
title="Account aktualisieren"
>
<IconRefresh className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
Refresh Today
</button>
</div>
{/* Zeile 4: Tages- & Streak-Status */}
{/* Bereich C (Zeile 4, Spalten 1-5) */}
<div className="col-span-5 row-span-1 flex items-center justify-between text-xs text-[var(--muted)]">
<span>{getLastRefreshText()}</span>
{streak != null && streak > 0 && (
<span>Streak: {streak} {streak === 1 ? 'day' : 'days'}</span>
)}
</div>
{/* Zeile 5: Datenqualitäts-Hinweis */}
{/* Bereich D (Zeile 5, Spalten 1-5) */}
<div className="col-span-5 row-span-1 flex items-center">
<div className="text-[10px] text-[var(--muted)]">
Data freshness: {getFreshnessLabel()}
</div>
</div>
</div>
</div>
);
}
function SalesCard({ sales, follower, responseTime, positiveReviews, neutralReviews, negativeReviews, totalReviews, className }) {
// Berechne Anteile für Balkendiagramm
// WICHTIG: null = nicht verfügbar, 0 = echter Wert
const total = (positiveReviews ?? 0) + (neutralReviews ?? 0) + (negativeReviews ?? 0);
const positivePercent = total > 0 ? ((positiveReviews ?? 0) / total) * 100 : 0;
const neutralPercent = total > 0 ? ((neutralReviews ?? 0) / total) * 100 : 0;
const negativePercent = total > 0 ? ((negativeReviews ?? 0) / total) * 100 : 0;
return (
<div
className={cn(
"row-span-1 flex h-full min-h-[12rem] flex-col rounded-xl border border-neutral-200 bg-white p-4 shadow-md transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-neutral-900 dark:shadow-none md:min-h-[18rem]",
className
)}
>
{/* Grid: 5 Zeilen x 10 Spalten */}
<div className="grid h-full grid-cols-10 grid-rows-5 gap-0">
{/* Zeile 1-2: Hero-Zone (40%) */}
{/* Block A: Sales (Spalten 1-4, Zeilen 1-2) */}
<div className="col-span-4 row-span-2 flex flex-col justify-center">
<div className="text-4xl font-bold text-[var(--text)] md:text-5xl">
{sales != null ? new Intl.NumberFormat("de-DE").format(sales) : ""}
</div>
<div className="mt-1 text-xs font-medium text-[var(--muted)]">Sales (gesamt)</div>
</div>
{/* Block B: Follower (Spalten 5-7, Zeilen 1-2) */}
<div className="col-span-3 row-span-2 flex flex-col justify-center">
<div className="text-2xl font-bold text-[var(--text)] md:text-3xl">
{follower != null ? new Intl.NumberFormat("de-DE").format(follower) : ""}
</div>
<div className="mt-1 text-xs font-medium text-[var(--muted)]">Follower</div>
</div>
{/* Block C: Antwortzeit (Spalten 8-10, Zeile 1) */}
<div className="col-span-3 row-span-1 flex flex-col justify-center">
<div className="text-xs font-medium text-[var(--muted)]">
Antwortzeit {responseTime ?? "—"}
</div>
</div>
{/* Zeile 2, Spalten 8-10: leer */}
<div className="col-span-3 row-span-1" />
{/* Zeile 3-4: Bewertungen (40%) */}
{/* Block D: Bewertungszusammenfassung (Spalten 1-10, Zeilen 3-4) */}
<div className="col-span-10 row-span-2 flex flex-col justify-center space-y-2">
<div className="text-xs font-medium text-[var(--muted)]">Bewertungen der letzten 12 Monate</div>
{/* Balkendiagramm */}
<div className="flex h-8 w-full items-center gap-0.5 overflow-hidden rounded border border-neutral-200 dark:border-neutral-700">
{positivePercent > 0 && (
<div
className="flex h-full items-center justify-center bg-green-500/20 text-[10px] font-medium text-green-700 dark:bg-green-500/30 dark:text-green-400"
style={{ width: `${positivePercent}%` }}
title={`Positiv: ${positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}`}
>
{positivePercent > 15 && (
<span className="truncate px-1">
{positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}
</span>
)}
</div>
)}
{neutralPercent > 0 && (
<div
className="flex h-full items-center justify-center bg-gray-400/20 text-[10px] font-medium text-gray-700 dark:bg-gray-400/30 dark:text-gray-400"
style={{ width: `${neutralPercent}%` }}
title={`Neutral: ${neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}`}
>
{neutralPercent > 15 && (
<span className="truncate px-1">
{neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}
</span>
)}
</div>
)}
{negativePercent > 0 && (
<div
className="flex h-full items-center justify-center bg-red-500/20 text-[10px] font-medium text-red-700 dark:bg-red-500/30 dark:text-red-400"
style={{ width: `${negativePercent}%` }}
title={`Negativ: ${negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}`}
>
{negativePercent > 15 && (
<span className="truncate px-1">
{negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}
</span>
)}
</div>
)}
</div>
{/* Zahlen unter dem Balken (falls Platz) */}
{positivePercent <= 15 && neutralPercent <= 15 && negativePercent <= 15 && (
<div className="flex gap-4 text-[10px] text-[var(--muted)]">
<span>Positiv: {positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"}</span>
<span>Neutral: {neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"}</span>
<span>Negativ: {negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"}</span>
</div>
)}
</div>
{/* Zeile 5: Meta-Informationen (20%) */}
{/* Block E: Gesamtbewertungen (Spalten 1-10, Zeile 5) */}
<div className="col-span-10 row-span-1 flex items-center">
<div className="text-xs text-[var(--muted)]">
Gesamtbewertungen: {totalReviews != null ? new Intl.NumberFormat("de-DE").format(totalReviews) : ""}
</div>
</div>
</div>
</div>
);
}
export const AccountsPage = () => { export const AccountsPage = () => {
const { navigate } = useHashRoute(); const { navigate } = useHashRoute();
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [formError, setFormError] = useState(""); const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState(""); const [formSuccess, setFormSuccess] = useState("");
@@ -32,6 +467,10 @@ export const AccountsPage = () => {
const [refreshingAccountId, setRefreshingAccountId] = useState(null); const [refreshingAccountId, setRefreshingAccountId] = useState(null);
const [refreshToast, setRefreshToast] = useState({ show: false, message: "", type: "success" }); const [refreshToast, setRefreshToast] = useState({ show: false, message: "", type: "success" });
// Nur ein Account wird angezeigt; Wechsel über Dropdown
const [displayedAccountId, setDisplayedAccountId] = useState(null);
// Form-Felder (nur noch URL) // Form-Felder (nur noch URL)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
account_url: "", account_url: "",
@@ -42,6 +481,22 @@ export const AccountsPage = () => {
loadAccounts(); loadAccounts();
}, []); }, []);
// displayedAccountId setzen sobald Accounts geladen (aktiv oder erster)
useEffect(() => {
if (accounts.length === 0) {
setDisplayedAccountId(null);
return;
}
const active = getActiveAccountId();
const hasActive = accounts.some((a) => (a.$id || a.id) === active);
if (hasActive) {
setDisplayedAccountId(active);
} else {
setDisplayedAccountId(accounts[0].$id || accounts[0].id);
}
}, [accounts]);
async function loadAccounts() { async function loadAccounts() {
setLoading(true); setLoading(true);
try { try {
@@ -61,11 +516,10 @@ export const AccountsPage = () => {
} }
} }
const handleSelectAccount = (account) => {
const accountId = account.$id || account.id; const handleDisplayedAccountChange = (accountId) => {
setDisplayedAccountId(accountId);
setActiveAccountId(accountId); setActiveAccountId(accountId);
// Navigiere zurück zum Dashboard
navigate("/");
}; };
const handleRefreshAccount = async (account) => { const handleRefreshAccount = async (account) => {
@@ -81,8 +535,16 @@ export const AccountsPage = () => {
setRefreshingAccountId(accountId); setRefreshingAccountId(accountId);
try { try {
// URL erneut parsen // URL erweitert parsen (mit Feedback, About, Store)
const parsedData = await parseEbayAccount(accountUrl); const parsedData = await parseViaExtensionExtended(accountUrl);
// Refresh-Status bestimmen basierend auf Partial Results
const refreshStatus = determineRefreshStatus(parsedData.partialResults, {
followers: parsedData.followers,
feedbackTotal: parsedData.feedbackTotal,
feedback12mPositive: parsedData.feedback12mPositive,
responseTimeHours: parsedData.responseTimeHours
});
// Account in DB aktualisieren // Account in DB aktualisieren
// WICHTIG: Nur Felder setzen, die nicht leer sind und sich geändert haben // WICHTIG: Nur Felder setzen, die nicht leer sind und sich geändert haben
@@ -101,17 +563,97 @@ export const AccountsPage = () => {
// Shop-Name kann auch leer sein (optional) // Shop-Name kann auch leer sein (optional)
updatePayload.account_shop_name = parsedData.shopName || null; updatePayload.account_shop_name = parsedData.shopName || null;
// account_sells existiert nicht im Schema - wurde entfernt updatePayload.account_sells = parsedData.stats?.itemsSold ?? null;
// #region agent log // Neue erweiterte Felder
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'AccountsPage.jsx:93',message:'handleRefreshAccount: update payload',data:{payload:updatePayload},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{}); updatePayload.account_response_time_hours = parsedData.responseTimeHours ?? null;
// #endregion updatePayload.account_followers = parsedData.followers ?? null;
updatePayload.account_feedback_total = parsedData.feedbackTotal ?? null;
updatePayload.account_feedback_12m_positive = parsedData.feedback12mPositive ?? null;
updatePayload.account_feedback_12m_neutral = parsedData.feedback12mNeutral ?? null;
updatePayload.account_feedback_12m_negative = parsedData.feedback12mNegative ?? null;
// Refresh-Metadaten
updatePayload.account_last_refresh_at = new Date().toISOString();
updatePayload.account_last_refresh_status = refreshStatus;
// account_status wird weggelassen (wie beim Erstellen) // account_status wird weggelassen (wie beim Erstellen)
// Grund: Schema-Konflikt - Enum-Feld akzeptiert weder String noch Array im Update // Grund: Schema-Konflikt - Enum-Feld akzeptiert weder String noch Array im Update
// TODO: Schema in Appwrite prüfen und korrigieren, dann account_status wieder hinzufügen // TODO: Schema in Appwrite prüfen und korrigieren, dann account_status wieder hinzufügen
await updateManagedAccount(accountId, updatePayload); // Setze account_updated_at auf aktuelle Zeit
updatePayload.account_updated_at = new Date().toISOString();
const updatedAccount = await updateManagedAccount(accountId, updatePayload);
// Berechne Sales-Differenz und speichere in account_metrics
try {
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd
// Lade letzte erfolgreiche Metrik für Sales-Differenz-Berechnung
const lastMetric = await getLastSuccessfulAccountMetric(accountId);
const currentSalesTotal = updatedAccount.account_sells;
// Berechne Sales-Differenz:
// Da account_metrics_last_sales_total nicht erlaubt ist, verwenden wir einen Workaround:
// - account_metrics_sales_count speichert den absoluten account_sells Wert
// - Beim nächsten Refresh berechnen wir die Differenz: currentSalesTotal - lastMetric.sales_count
// - Die Differenz wird in account_metrics_sales_bucket gespeichert (als Bucket-String)
// - Für die Anzeige im Kalender verwenden wir den Bucket-String
let salesDifference = null;
if (lastMetric && lastMetric.account_metrics_sales_count !== null && currentSalesTotal !== null) {
// Letzte Metrik existiert: Berechne Differenz
// lastMetric.account_metrics_sales_count ist der absolute Wert vom letzten Refresh
const lastAbsoluteValue = lastMetric.account_metrics_sales_count;
salesDifference = currentSalesTotal - lastAbsoluteValue;
// Stelle sicher, dass Differenz nicht negativ ist (falls account_sells zurückgesetzt wurde)
if (salesDifference < 0) {
salesDifference = null;
}
}
// Speichere absoluten Wert in sales_count (für nächsten Refresh)
// Die Differenz wird in sales_bucket gespeichert (berechnet via calculateSalesBucket)
const salesCountToStore = currentSalesTotal;
// Upsert account_metrics für heute
// WICHTIG: Da account_metrics_last_sales_total nicht erlaubt ist, speichern wir:
// - account_metrics_sales_count: absoluten account_sells Wert (für nächsten Refresh)
// - account_metrics_sales_bucket: Bucket basierend auf der Differenz (wenn berechenbar)
//
// Beim nächsten Refresh: Differenz = currentSalesTotal - lastMetric.sales_count
// Berechne Bucket aus Differenz (falls berechenbar)
const { calculateSalesBucket } = await import("../services/accountMetricsService");
const bucket = salesDifference !== null ? calculateSalesBucket(salesDifference) : null;
// Erstelle/Update Metrik mit absolutem Wert
const metricDoc = await upsertAccountMetric(accountId, todayStr, {
refreshed: true,
refreshStatus: refreshStatus === "success" ? "success" : "failed",
refreshedAt: new Date().toISOString(),
salesCount: currentSalesTotal // Absoluter Wert für nächsten Refresh
});
// Update sales_bucket separat (da upsertAccountMetric Bucket aus salesCount berechnet)
if (bucket !== null && metricDoc) {
await databases.updateDocument(
databaseId,
"account_metrics",
metricDoc.$id,
{ account_metrics_sales_bucket: bucket }
);
}
// Lade Monats-Metriken neu
await loadMonthMetrics(accountId);
} catch (metricsError) {
// Nicht kritisch, nur loggen
console.warn("Fehler beim Erstellen der Account-Metrik:", metricsError);
}
// Accounts-Liste neu laden (in-place Update) // Accounts-Liste neu laden (in-place Update)
await loadAccounts(); await loadAccounts();
@@ -129,6 +671,25 @@ export const AccountsPage = () => {
setRefreshToast({ show: true, message: errorMessage, type: "error" }); setRefreshToast({ show: true, message: errorMessage, type: "error" });
setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000); setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000);
// Auch bei Fehler: Metrik für heute speichern (mit failed Status)
try {
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd
await upsertAccountMetric(accountId, todayStr, {
refreshed: false,
refreshStatus: "failed",
refreshedAt: null,
salesCount: null
});
// Lade Monats-Metriken neu
await loadMonthMetrics(accountId);
} catch (metricsError) {
// Nicht kritisch, nur loggen
console.warn("Fehler beim Erstellen der Account-Metrik (failed):", metricsError);
}
} finally { } finally {
setRefreshingAccountId(null); setRefreshingAccountId(null);
} }
@@ -206,9 +767,6 @@ export const AccountsPage = () => {
// Payload aus parsedData zusammenstellen // Payload aus parsedData zusammenstellen
const accountSellsValue = parsedData.stats?.itemsSold ?? null; const accountSellsValue = parsedData.stats?.itemsSold ?? null;
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'AccountsPage.jsx:193',message:'handleFormSubmit: parsedData before save',data:{hasStats:!!parsedData.stats,itemsSold:parsedData.stats?.itemsSold,accountSellsValue},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
const newAccount = await createManagedAccount(authUser.$id, { const newAccount = await createManagedAccount(authUser.$id, {
account_url: formData.account_url.trim(), account_url: formData.account_url.trim(),
account_platform_account_id: parsedData.sellerId, account_platform_account_id: parsedData.sellerId,
@@ -240,103 +798,8 @@ export const AccountsPage = () => {
} }
}; };
// Spalten für die Tabelle const displayedAccount =
const columns = [ accounts.find((a) => (a.$id || a.id) === displayedAccountId) ?? accounts[0] ?? null;
"Account Name",
"Platform",
"Platform Account ID",
"Market",
"Account URL",
"Status",
"Last Scan",
...(showAdvanced ? ["Owner User ID"] : []),
"Action",
];
const renderCell = (col, row) => {
if (col === "Action") {
const accountId = row.$id || row.id;
const isRefreshing = refreshingAccountId === accountId;
return (
<div className="flex items-center gap-2">
<button
onClick={() => handleRefreshAccount(row)}
disabled={isRefreshing}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px] disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
title="Account aktualisieren"
>
<IconRefresh className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")} />
Refresh
</button>
<button
onClick={() => handleSelectAccount(row)}
className="rounded-xl border border-[var(--line)] bg-white/3 px-3 py-2 text-xs text-[var(--text)] transition-all hover:border-[rgba(106,166,255,0.55)] hover:bg-[rgba(106,166,255,0.08)] active:translate-y-[1px]"
>
Select
</button>
</div>
);
}
if (col === "Account Name") {
return getAccountDisplayName(row) || "-";
}
if (col === "Platform") {
return row.account_platform || "-";
}
if (col === "Platform Account ID") {
return row.account_platform_account_id || "-";
}
if (col === "Market") {
return row.account_platform_market || "-";
}
if (col === "Account URL") {
const url = row.account_url;
if (!url) return "-";
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline dark:text-blue-400"
>
{url.length > 40 ? `${url.substring(0, 40)}...` : url}
</a>
);
}
if (col === "Status") {
return row.account_status || "-";
}
if (col === "Last Scan") {
const lastScan = row.account_last_scan_at;
if (!lastScan) return "-";
try {
const date = new Date(lastScan);
return date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch (e) {
return "-";
}
}
if (col === "Owner User ID") {
return row.account_owner_user_id || "-";
}
return "-";
};
return ( return (
<div className="flex flex-1"> <div className="flex flex-1">
@@ -360,72 +823,6 @@ export const AccountsPage = () => {
</button> </button>
</div> </div>
{/* Hilfe-Panel */}
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background:
"radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
<h2 className="mb-3 text-sm font-semibold text-[var(--text)]">
Account hinzufügen
</h2>
<div className="grid gap-3 text-xs text-[var(--muted)]">
<div>
<span className="font-medium text-[var(--text)]">
eBay Account URL <span className="text-red-500">(Pflichtfeld)</span>
</span>
: Gib einfach die eBay-URL zum Verkäuferprofil oder Shop ein. Alle weiteren Informationen (Market, Seller ID, Shop Name) werden automatisch erkannt.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Market (Auto)
</span>
: Wird automatisch aus der URL extrahiert (z.B. DE, US, UK). Du musst nichts eingeben.
</div>
<div>
<span className="font-medium text-[var(--text)]">
eBay Seller ID (Auto)
</span>
: Wird automatisch erkannt. Dies ist die eindeutige Verkäufer-Kennung von eBay und verhindert Duplikate.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Shop Name (Auto)
</span>
: Öffentlich sichtbarer Name des Shops. Wird automatisch aus der URL/Seite extrahiert.
</div>
<div>
<span className="font-medium text-[var(--text)]">
Status (Auto)
</span>
: Wird automatisch auf "active" gesetzt. Du musst nichts eingeben.
</div>
</div>
<div className="mt-4 rounded-lg border border-[var(--line)] bg-white/2 p-3 text-xs text-[var(--muted)]">
<span className="font-medium text-[var(--text)]">So funktioniert's:</span>{" "}
Gib einfach die eBay-URL ein und klicke auf "Account hinzufügen". Das System liest alle notwendigen Informationen automatisch aus. Du musst keine technischen Felder manuell ausfüllen.
</div>
{/* Advanced Toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="mt-4 flex items-center gap-2 text-xs text-[var(--muted)] transition-colors hover:text-[var(--text)]"
>
<IconChevronDown
className={cn(
"h-4 w-4 transition-transform",
showAdvanced && "rotate-180"
)}
/>
{showAdvanced ? "Weniger anzeigen" : "Erweitert anzeigen"}
</button>
</div>
</div>
{/* Toast Notification */} {/* Toast Notification */}
<AnimatePresence> <AnimatePresence>
{refreshToast.show && ( {refreshToast.show && (
@@ -445,24 +842,66 @@ export const AccountsPage = () => {
)} )}
</AnimatePresence> </AnimatePresence>
{/* Tabelle */} {/* Bento Grid nur ein Account */}
<div className="relative overflow-hidden rounded-[18px] border border-[var(--line)] bg-gradient-to-b from-white/4 to-white/2 p-4 shadow-[0_10px_30px_rgba(0,0,0,0.35)]"> <div className="flex flex-1 flex-col gap-8 overflow-y-auto">
<div
className="pointer-events-none absolute inset-[-1px] opacity-55"
style={{
background:
"radial-gradient(600px 280px at 20% 0%, rgba(106,166,255,0.14), transparent 60%)",
}}
/>
<div className="relative">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]"> <div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
Loading accounts... Loading accounts...
</div> </div>
) : ( ) : !displayedAccount ? (
<DataTable columns={columns} data={accounts} renderCell={renderCell} /> <div className="flex items-center justify-center py-12 text-sm text-[var(--muted)]">
)} No accounts yet. Add one above.
</div> </div>
) : (
(() => {
const account = displayedAccount;
const accountId = account.$id || account.id;
const isRefreshing = refreshingAccountId === accountId;
const name = getAccountDisplayName(account) || "";
const url = account.account_url;
const sales =
account.account_sells != null
? new Intl.NumberFormat("de-DE").format(account.account_sells)
: "";
return (
<BentoGrid key={accountId} className="max-w-4xl mx-auto md:auto-rows-[18rem]">
<AccountNameCard
name={name}
url={url}
platformAccountId={account.account_platform_account_id}
accounts={accounts}
displayedAccountId={displayedAccountId}
onSelectAccount={handleDisplayedAccountChange}
className="md:col-span-1"
/>
<PlatformLogoCard
platform={account.account_platform}
market={account.account_platform_market}
className="md:col-span-1"
/>
<RangCard rank={undefined} className="md:col-span-1" />
<SalesCard
sales={account.account_sells ?? null}
follower={account.account_followers ?? null}
responseTime={account.account_response_time_hours ? `< ${account.account_response_time_hours}h` : null}
positiveReviews={account.account_feedback_12m_positive ?? null}
neutralReviews={account.account_feedback_12m_neutral ?? null}
negativeReviews={account.account_feedback_12m_negative ?? null}
totalReviews={account.account_feedback_total ?? null}
className="md:col-span-2"
/>
<AccountRefreshCard
onRefresh={() => handleRefreshAccount(account)}
isRefreshing={isRefreshing}
lastRefreshDate={account.account_last_refresh_at || null}
streak={account.account_refresh_streak || null}
dataFreshness={calculateDataFreshness(account.account_last_refresh_at) || 'Aging'}
/>
</BentoGrid>
);
})()
)}
</div> </div>
{/* Add Account Form Modal */} {/* Add Account Form Modal */}
@@ -587,31 +1026,16 @@ export const AccountsPage = () => {
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]"> <label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Artikel verkauft (Auto) Sales (Auto)
</label> </label>
<input <input
type="text" type="text"
value={parsedData.stats?.itemsSold ?? "-"} value={parsedData.stats?.itemsSold ? new Intl.NumberFormat("de-DE").format(parsedData.stats.itemsSold) : "-"}
readOnly readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700" className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/> />
<p className="mt-1 text-xs text-[var(--muted)]"> <p className="mt-1 text-xs text-[var(--muted)]">
Automatisch aus dem eBay-Profil gelesen. Gesamtzahl der verkauften Artikel.
</p>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">
Status (Auto)
</label>
<input
type="text"
value={parsedData.status}
readOnly
className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-[var(--text)] opacity-75 dark:bg-neutral-900 dark:border-neutral-700"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Interner Status. Normalerweise "active".
</p> </p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,7 @@
"use client";
import React from "react";
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
export const AnalysisPage = () => {
return <ScrollSnapDummyPage pageTitle="Analysis" />;
};

View File

@@ -0,0 +1,7 @@
"use client";
import React from "react";
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
export const BlacklistPage = () => {
return <ScrollSnapDummyPage pageTitle="Blacklist" />;
};

View File

@@ -0,0 +1,7 @@
"use client";
import React from "react";
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
export const ItemsPage = () => {
return <ScrollSnapDummyPage pageTitle="Items" />;
};

View File

@@ -0,0 +1,219 @@
/**
* Account Metrics Service
* CRUD-Operationen für account_metrics Collection
* Verwaltet tägliche Account-Metriken (Refresh-Status, Sales-Daten)
*/
import { databases, databaseId } from "../lib/appwrite";
import { ID, Query } from "appwrite";
const accountMetricsCollectionId = "account_metrics";
/**
* Berechnet Sales-Bucket aus Sales-Count
* @param {number|null} salesCount - Sales-Count oder null
* @returns {string|null} Bucket ("1+", "5+", "10+", "50+") oder null
*/
export function calculateSalesBucket(salesCount) {
if (salesCount === null || salesCount === undefined || salesCount === 0) {
return null;
}
if (salesCount >= 1 && salesCount <= 4) {
return "1+";
}
if (salesCount >= 5 && salesCount <= 9) {
return "5+";
}
if (salesCount >= 10 && salesCount <= 49) {
return "10+";
}
if (salesCount >= 50) {
return "50+";
}
return null;
}
/**
* Formatiert Datum zu "yyyy-mm-dd" String
* @param {Date|string} date - Date-Objekt oder ISO-String
* @returns {string} Formatierte Datum-String
*/
function formatDate(date) {
if (typeof date === 'string') {
// Wenn bereits String, stelle sicher dass es yyyy-mm-dd Format ist
const d = new Date(date);
return d.toISOString().split('T')[0];
}
return date.toISOString().split('T')[0];
}
/**
* Formatiert Datum zu "yyyy-mm" String
* @param {Date|string} date - Date-Objekt oder ISO-String
* @returns {string} Formatierte Monat-String
*/
function formatMonth(date) {
const dateStr = formatDate(date);
return dateStr.substring(0, 7); // "yyyy-mm"
}
/**
* Erstellt oder aktualisiert eine Account-Metrik für einen Tag
* @param {string} accountId - ID des Accounts
* @param {string|Date} date - Datum (yyyy-mm-dd String oder Date-Objekt)
* @param {Object} data - Metrik-Daten
* @param {boolean} data.refreshed - Refresh-Status
* @param {string} data.refreshStatus - "success" | "failed"
* @param {number|null} [data.salesCount] - Sales-Differenz
* @returns {Promise<Object>} Erstelltes oder aktualisiertes Metrik-Dokument
*/
export async function upsertAccountMetric(accountId, date, data) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
const dateStr = formatDate(date);
const monthStr = formatMonth(date);
// Berechne Sales-Bucket aus salesCount
const salesBucket = calculateSalesBucket(data.salesCount);
// Prüfe ob Metrik für diesen Tag bereits existiert
try {
const existing = await databases.listDocuments(
databaseId,
accountMetricsCollectionId,
[
Query.equal("account_metrics_account_id", accountId),
Query.equal("account_metrics_date", dateStr),
Query.limit(1)
]
);
const payload = {
account_metrics_account_id: accountId,
account_metrics_date: dateStr,
account_metrics_month: monthStr,
account_metrics_refreshed: data.refreshed,
account_metrics_refresh_status: data.refreshStatus,
account_metrics_refreshed_at: data.refreshedAt || null,
account_metrics_sales_count: data.salesCount ?? null,
account_metrics_sales_bucket: salesBucket
};
// Speichere lastSalesTotal in rawSnapshot (falls vorhanden)
// Da rawSnapshot nicht in der erlaubten Liste ist, speichern wir es nicht
// Stattdessen: lastSalesTotal wird beim nächsten Refresh aus dem Account-Dokument geholt
let result;
if (existing.documents.length > 0) {
// Update existing document
const existingDoc = existing.documents[0];
result = await databases.updateDocument(
databaseId,
accountMetricsCollectionId,
existingDoc.$id,
payload
);
} else {
// Create new document
result = await databases.createDocument(
databaseId,
accountMetricsCollectionId,
ID.unique(),
payload
);
}
return result;
} catch (e) {
console.error("Fehler beim Upsert der Account-Metrik:", e);
throw e;
}
}
/**
* Lädt alle Account-Metriken für einen Monat
* @param {string} accountId - ID des Accounts
* @param {number} year - Jahr (z.B. 2026)
* @param {number} month - Monat (1-12)
* @returns {Promise<Map<string, Object>>} Map von date (yyyy-mm-dd) -> Metrik-Dokument
*/
export async function fetchAccountMetricsForMonth(accountId, year, month) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
// Formatiere Monat zu "yyyy-mm"
const monthStr = `${year}-${String(month).padStart(2, '0')}`;
try {
const response = await databases.listDocuments(
databaseId,
accountMetricsCollectionId,
[
Query.equal("account_metrics_account_id", accountId),
Query.equal("account_metrics_month", monthStr),
Query.orderAsc("account_metrics_date")
]
);
// Konvertiere Array zu Map: date -> metric
const metricsMap = new Map();
for (const doc of response.documents) {
const date = doc.account_metrics_date;
if (date) {
metricsMap.set(date, doc);
}
}
return metricsMap;
} catch (e) {
// Wenn Collection nicht existiert, gib leere Map zurück
if (e.code === 404 || e.type === 'collection_not_found') {
console.warn("account_metrics Collection existiert noch nicht. Bitte Schema erstellen.");
return new Map();
}
console.error("Fehler beim Laden der Account-Metriken:", e);
throw e;
}
}
/**
* Lädt die letzte erfolgreiche Account-Metrik
* @param {string} accountId - ID des Accounts
* @returns {Promise<Object|null>} Letzte erfolgreiche Metrik oder null
*/
export async function getLastSuccessfulAccountMetric(accountId) {
if (!accountId) {
throw new Error("accountId ist erforderlich");
}
try {
const response = await databases.listDocuments(
databaseId,
accountMetricsCollectionId,
[
Query.equal("account_metrics_account_id", accountId),
Query.equal("account_metrics_refresh_status", "success"),
Query.orderDesc("account_metrics_date"),
Query.limit(1)
]
);
if (response.documents.length > 0) {
return response.documents[0];
}
return null;
} catch (e) {
// Wenn Collection nicht existiert, gib null zurück
if (e.code === 404 || e.type === 'collection_not_found') {
return null;
}
console.error("Fehler beim Laden der letzten erfolgreichen Metrik:", e);
throw e;
}
}

View File

@@ -6,6 +6,7 @@
import { databases, databaseId, accountsCollectionId } from "../lib/appwrite"; import { databases, databaseId, accountsCollectionId } from "../lib/appwrite";
import { ID, Query } from "appwrite"; import { ID, Query } from "appwrite";
import { getLastSuccessfulAccountMetric as getLastSuccessfulAccountMetricFromService } from "./accountMetricsService";
/** /**
* Lädt ein einzelnes Account nach ID * Lädt ein einzelnes Account nach ID
@@ -200,11 +201,17 @@ export async function createManagedAccount(authUserId, accountData) {
account_platform_account_id, account_platform_account_id,
account_shop_name: accountData.account_shop_name || null, account_shop_name: accountData.account_shop_name || null,
account_url: accountData.account_url || null, account_url: accountData.account_url || null,
// account_sells und account_managed existieren nicht im Schema - wurden entfernt account_sells: accountData.account_sells ?? null, // Setze account_sells wenn verfügbar
// Neue Felder
account_response_time_hours: accountData.account_response_time_hours ?? null,
account_followers: accountData.account_followers ?? null,
account_feedback_total: accountData.account_feedback_total ?? null,
account_feedback_12m_positive: accountData.account_feedback_12m_positive ?? null,
account_feedback_12m_neutral: accountData.account_feedback_12m_neutral ?? null,
account_feedback_12m_negative: accountData.account_feedback_12m_negative ?? null,
account_last_refresh_at: accountData.account_last_refresh_at ?? null,
account_last_refresh_status: accountData.account_last_refresh_status ?? null,
}; };
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:72',message:'createManagedAccount: payload before Appwrite',data:{account_sells:payload.account_sells,accountData_account_sells:accountData.account_sells},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{});
// #endregion
// account_status ist optional - aufgrund Schema-Konflikt vorerst weglassen // account_status ist optional - aufgrund Schema-Konflikt vorerst weglassen
// TODO: Schema in Appwrite prüfen und korrigieren (Enum-Feld sollte String akzeptieren, nicht Array) // TODO: Schema in Appwrite prüfen und korrigieren (Enum-Feld sollte String akzeptieren, nicht Array)
@@ -260,10 +267,6 @@ export async function updateManagedAccount(accountId, accountData) {
} }
}); });
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:133',message:'updateManagedAccount: before updateDocument',data:{accountId,payload,payloadKeys:Object.keys(payload)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
const document = await databases.updateDocument( const document = await databases.updateDocument(
databaseId, databaseId,
accountsCollectionId, accountsCollectionId,
@@ -271,15 +274,9 @@ export async function updateManagedAccount(accountId, accountData) {
payload payload
); );
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:147',message:'updateManagedAccount: success',data:{accountId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
// #endregion
return document; return document;
} catch (e) { } catch (e) {
console.error("Fehler beim Aktualisieren des Accounts:", e); console.error("Fehler beim Aktualisieren des Accounts:", e);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'accountsService.js:149',message:'updateManagedAccount: error',data:{accountId,errorCode:e.code,errorMessage:e.message,errorType:e.type},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion
throw e; throw e;
} }
} }
@@ -336,3 +333,73 @@ export async function deleteManagedAccount(accountId) {
throw e; throw e;
} }
} }
/**
* Berechnet Data Freshness zur Laufzeit basierend auf account_last_refresh_at
* @param {string|null} lastRefreshAt - ISO 8601 Datum-String oder null
* @returns {string|null} "fresh" | "aging" | "outdated" | null
*/
export function calculateDataFreshness(lastRefreshAt) {
if (!lastRefreshAt) {
return null;
}
try {
const refreshDate = new Date(lastRefreshAt);
const now = new Date();
const diffMs = now - refreshDate;
const diffDays = diffMs / (1000 * 60 * 60 * 24);
if (diffDays < 1) {
return "fresh";
} else if (diffDays <= 3) {
return "aging";
} else {
return "outdated";
}
} catch (e) {
console.warn("Error calculating data freshness:", e);
return null;
}
}
/**
* Bestimmt Refresh-Status basierend auf Partial Results
* Kernfelder: followers + feedbackTotal + (feedback12mPositive ODER responseTimeHours)
* @param {object} partialResults - Partial Results Objekt mit ok/error pro Sub-Scan
* @param {object} data - Extrahierte Daten (responseTimeHours, followers, feedbackTotal, feedback12mPositive)
* @returns {string} "success" | "partial" | "failed"
*/
export function determineRefreshStatus(partialResults, data) {
if (!partialResults || !data) {
return "failed";
}
// Kernfelder prüfen
const hasFollowers = data.followers !== null && data.followers !== undefined;
const hasFeedbackTotal = data.feedbackTotal !== null && data.feedbackTotal !== undefined;
const hasFeedback12m = data.feedback12mPositive !== null && data.feedback12mPositive !== undefined;
const hasResponseTime = data.responseTimeHours !== null && data.responseTimeHours !== undefined;
const hasThirdField = hasFeedback12m || hasResponseTime;
const coreFieldsCount = (hasFollowers ? 1 : 0) + (hasFeedbackTotal ? 1 : 0) + (hasThirdField ? 1 : 0);
if (coreFieldsCount >= 2) {
return "success";
} else if (coreFieldsCount === 1) {
return "partial";
} else {
return "failed";
}
}
/**
* Lädt die letzte erfolgreiche Account-Metrik
* Wrapper um accountMetricsService.getLastSuccessfulAccountMetric()
* @param {string} accountId - ID des Accounts
* @returns {Promise<Object|null>} Letzte erfolgreiche Metrik oder null
*/
export async function getLastSuccessfulAccountMetric(accountId) {
return await getLastSuccessfulAccountMetricFromService(accountId);
}

View File

@@ -129,9 +129,6 @@ async function getExtensionId() {
try { try {
// Methode 1: Verwende gecachte Extension-ID (via postMessage vom Content Script empfangen) // Methode 1: Verwende gecachte Extension-ID (via postMessage vom Content Script empfangen)
if (cachedExtensionId) { if (cachedExtensionId) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:135',message:'getExtensionId: found via cache',data:{cachedExtensionId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
return cachedExtensionId; return cachedExtensionId;
} }
@@ -147,9 +144,6 @@ async function getExtensionId() {
} }
} }
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:150',message:'getExtensionId: not found after retries',data:{hasWindow:typeof window!=='undefined'},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
return null; // Nicht verfügbar return null; // Nicht verfügbar
} catch (e) { } catch (e) {
return null; return null;
@@ -186,25 +180,30 @@ async function parseViaExtension(url) {
// Check for Chrome runtime errors // Check for Chrome runtime errors
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
const errorMsg = chrome.runtime.lastError.message || "Extension communication error"; const errorMsg = chrome.runtime.lastError.message || "Extension communication error";
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:158',message:'parseViaExtension: chrome.runtime.sendMessage error',data:{error:errorMsg,extensionId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
reject(new Error(errorMsg)); reject(new Error(errorMsg));
return; return;
} }
if (response && response.ok && response.data) { if (response && response.ok && response.data) {
// Ensure stats object is included (even if empty) // Ensure stats object is included (even if empty)
let sellerId = response.data.sellerId || "";
// Fallback: Wenn Extension keinen sellerId liefert, versuche aus URL zu extrahieren
if (!sellerId || sellerId.trim() === "") {
// Versuche _ssn Parameter aus Suchergebnis-URLs zu extrahieren
const ssnMatch = url.match(/[?&]_ssn=([^&]+)/i);
if (ssnMatch && ssnMatch[1]) {
sellerId = decodeURIComponent(ssnMatch[1]).trim();
}
}
const data = { const data = {
sellerId: response.data.sellerId || "", sellerId: sellerId,
shopName: response.data.shopName || "", shopName: response.data.shopName || "",
market: response.data.market || "US", market: response.data.market || "US",
status: response.data.status || "unknown", status: response.data.status || "unknown",
stats: response.data.stats || {} stats: response.data.stats || {}
}; };
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:160',message:'parseViaExtension: response data from extension',data:{hasStats:!!response.data.stats,itemsSold:response.data.stats?.itemsSold,stats:response.data.stats},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
resolve(data); resolve(data);
} else { } else {
reject(new Error(response?.error || "Extension parsing failed")); reject(new Error(response?.error || "Extension parsing failed"));
@@ -213,9 +212,6 @@ async function parseViaExtension(url) {
// Benötigt Extension-ID (sendMessage von Webseiten aus erfordert immer Extension-ID) // Benötigt Extension-ID (sendMessage von Webseiten aus erfordert immer Extension-ID)
if (!extensionId) { if (!extensionId) {
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:175',message:'parseViaExtension: no extension ID',data:{hasWindowExtensionId:!!(typeof window!=='undefined'&&window.__EBAY_EXTENSION_ID__)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
reject(new Error("Extension ID not available")); reject(new Error("Extension ID not available"));
return; return;
} }
@@ -250,8 +246,19 @@ async function parseViaExtension(url) {
window.removeEventListener('message', responseHandler); window.removeEventListener('message', responseHandler);
if (event.data?.ok && event.data?.data) { if (event.data?.ok && event.data?.data) {
let sellerId = event.data.data.sellerId || "";
// Fallback: Wenn Extension keinen sellerId liefert, versuche aus URL zu extrahieren
if (!sellerId || sellerId.trim() === "") {
// Versuche _ssn Parameter aus Suchergebnis-URLs zu extrahieren
const ssnMatch = url.match(/[?&]_ssn=([^&]+)/i);
if (ssnMatch && ssnMatch[1]) {
sellerId = decodeURIComponent(ssnMatch[1]).trim();
}
}
const data = { const data = {
sellerId: event.data.data.sellerId || "", sellerId: sellerId,
shopName: event.data.data.shopName || "", shopName: event.data.data.shopName || "",
market: event.data.data.market || "US", market: event.data.data.market || "US",
status: event.data.data.status || "unknown", status: event.data.data.status || "unknown",
@@ -315,8 +322,11 @@ export async function parseViaExtensionScanProducts(url, accountId) {
accountId: accountId accountId: accountId
}; };
const SCAN_TIMEOUT_MS = 300000;
// SendMessage-Callback // SendMessage-Callback
const sendMessageCallback = (response) => { const sendMessageCallback = (response) => {
clearTimeout(timeoutId);
// Check for Chrome runtime errors // Check for Chrome runtime errors
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
const errorMsg = chrome.runtime.lastError.message || "Extension communication error"; const errorMsg = chrome.runtime.lastError.message || "Extension communication error";
@@ -324,11 +334,12 @@ export async function parseViaExtensionScanProducts(url, accountId) {
return; return;
} }
if (response && response.ok && response.data) { console.log("[ebayParserService] SCAN_PRODUCTS response:", response);
const items = response.data.items || [];
const meta = response.data.meta || response.meta || {}; if (response && response.ok) {
const items = response.data?.items || response.items || [];
const meta = response.data?.meta || response.meta || {};
// Wenn items leer oder ok:false, werfe Fehler mit meta
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
const errorMsg = response?.error || "no_items_found"; const errorMsg = response?.error || "no_items_found";
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`); const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
@@ -337,30 +348,27 @@ export async function parseViaExtensionScanProducts(url, accountId) {
return; return;
} }
// Erfolg: gib items zurück
resolve(items); resolve(items);
} else { } else {
// Fehler: sende error + meta
const meta = response?.meta || {}; const meta = response?.meta || {};
const errorMsg = response?.error || "Extension scanning failed"; const errorMsg = response?.error || "Extension scanning failed";
console.log("[ebayParserService] SCAN_PRODUCTS error:", errorMsg, "meta:", meta);
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`); const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
error.meta = meta; error.meta = meta;
reject(error); reject(error);
} }
}; };
// Benötigt Extension-ID (sendMessage von Webseiten aus erfordert immer Extension-ID)
if (!extensionId) { if (!extensionId) {
reject(new Error("Extension ID not available")); reject(new Error("Extension ID not available"));
return; return;
} }
chrome.runtime.sendMessage(extensionId, message, sendMessageCallback); const timeoutId = setTimeout(() => {
// Timeout nach 20s (Extension hat intern 20s)
setTimeout(() => {
reject(new Error("Extension timeout")); reject(new Error("Extension timeout"));
}, 20000); }, SCAN_TIMEOUT_MS);
chrome.runtime.sendMessage(extensionId, message, sendMessageCallback);
}); });
} catch (error) { } catch (error) {
// Chrome API Fehler: weiter zu Methode 2 // Chrome API Fehler: weiter zu Methode 2
@@ -373,45 +381,51 @@ export async function parseViaExtensionScanProducts(url, accountId) {
// Methode 2: Window flag + postMessage (falls Content Script Relay vorhanden) // Methode 2: Window flag + postMessage (falls Content Script Relay vorhanden)
if (typeof window !== 'undefined' && window.__EBAY_EXTENSION__ === true) { if (typeof window !== 'undefined' && window.__EBAY_EXTENSION__ === true) {
const SCAN_TIMEOUT_MS = 300000;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const messageId = `scan_${Date.now()}_${Math.random()}`; const messageId = `scan_${Date.now()}_${Math.random()}`;
let settled = false;
const finish = (fn) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
window.removeEventListener('message', responseHandler);
fn();
};
// Listener für Antwort
const responseHandler = (event) => { const responseHandler = (event) => {
if (event.data?.source !== 'eship-extension' || event.data?.messageId !== messageId) { if (event.data?.source !== 'eship-extension' || event.data?.messageId !== messageId) {
return; return;
} }
window.removeEventListener('message', responseHandler);
if (event.data?.ok && event.data?.data) { if (event.data?.ok && event.data?.data) {
const items = event.data.data.items || event.data.items || []; const items = event.data.data.items || event.data.items || [];
const meta = event.data.data.meta || event.data.meta || {}; const meta = event.data.data.meta || event.data.meta || {};
// Wenn items leer oder ok:false, werfe Fehler mit meta
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
const errorMsg = event.data?.error || "no_items_found"; const errorMsg = event.data?.error || "no_items_found";
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`); const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
error.meta = meta; error.meta = meta;
reject(error); finish(() => reject(error));
return; return;
} }
finish(() => resolve(items));
// Erfolg: gib items zurück
resolve(items);
} else { } else {
// Fehler: sende error + meta
const meta = event.data?.meta || event.data?.data?.meta || {}; const meta = event.data?.meta || event.data?.data?.meta || {};
const errorMsg = event.data?.error || "Extension scanning failed"; const errorMsg = event.data?.error || "Extension scanning failed";
const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`); const error = new Error(`SCAN_PRODUCTS failed: ${errorMsg} (${meta.pageType || "unknown"})`);
error.meta = meta; error.meta = meta;
reject(error); finish(() => reject(error));
} }
}; };
window.addEventListener('message', responseHandler); window.addEventListener('message', responseHandler);
// Sende Request via postMessage const timeoutId = setTimeout(() => {
finish(() => reject(new Error("Extension timeout")));
}, SCAN_TIMEOUT_MS);
window.postMessage({ window.postMessage({
source: 'eship-webapp', source: 'eship-webapp',
action: 'SCAN_PRODUCTS', action: 'SCAN_PRODUCTS',
@@ -419,12 +433,6 @@ export async function parseViaExtensionScanProducts(url, accountId) {
accountId: accountId, accountId: accountId,
messageId: messageId messageId: messageId
}, '*'); }, '*');
// Timeout
setTimeout(() => {
window.removeEventListener('message', responseHandler);
reject(new Error("Extension timeout"));
}, 20000);
}); });
} }
@@ -432,6 +440,31 @@ export async function parseViaExtensionScanProducts(url, accountId) {
throw new Error("Extension not available"); throw new Error("Extension not available");
} }
/**
* Holt den aktuellen Scan-Fortschritt von der Extension (für Polling während SCAN_PRODUCTS).
* @returns {Promise<{ percent: number, phase: string, total: number, current: number, complete: boolean }|null>}
*/
export async function getScanProgress() {
if (typeof chrome === "undefined" || !chrome?.runtime?.sendMessage) {
return null;
}
try {
const extensionId = await getExtensionId();
if (!extensionId) return null;
return new Promise((resolve) => {
chrome.runtime.sendMessage(extensionId, { action: "GET_SCAN_PROGRESS" }, (response) => {
if (chrome.runtime.lastError) {
resolve(null);
return;
}
resolve(response ?? null);
});
});
} catch {
return null;
}
}
/** /**
* Parst eine eBay-URL mit Stub-Logik (deterministisch, keine Network-Calls) * Parst eine eBay-URL mit Stub-Logik (deterministisch, keine Network-Calls)
* @param {string} url - eBay-Verkäuferprofil oder Shop-URL * @param {string} url - eBay-Verkäuferprofil oder Shop-URL
@@ -457,9 +490,18 @@ async function parseViaStub(url) {
const hash = stableHash(url); const hash = stableHash(url);
const market = extractMarketFromUrl(url); const market = extractMarketFromUrl(url);
// Seller ID: Deterministic aus URL-Hash // Seller ID: Versuche zuerst aus URL zu extrahieren (_ssn Parameter)
let sellerId = "";
const ssnMatch = url.match(/[?&]_ssn=([^&]+)/i);
if (ssnMatch && ssnMatch[1]) {
sellerId = decodeURIComponent(ssnMatch[1]).trim();
}
// Fallback: Deterministic aus URL-Hash wenn kein _ssn Parameter gefunden
if (!sellerId || sellerId.trim() === "") {
// Format: "ebay_" + hash (first 10 chars) // Format: "ebay_" + hash (first 10 chars)
const sellerId = `ebay_${hash.slice(0, 10)}`; sellerId = `ebay_${hash.slice(0, 10)}`;
}
// Shop Name: Generiert aus Hash (last 4 chars als Suffix) // Shop Name: Generiert aus Hash (last 4 chars als Suffix)
const shopNameSuffix = hash.slice(-4); const shopNameSuffix = hash.slice(-4);
@@ -479,6 +521,101 @@ async function parseViaStub(url) {
}; };
} }
/**
* Parst eine eBay-URL erweitert und extrahiert Account-Daten inkl. Feedback, Response Time, Followers
* @param {string} url - eBay-Verkäuferprofil oder Shop-URL
* @returns {Promise<{sellerId, shopName, market, status, stats, responseTimeHours, followers, feedbackTotal, feedback12mPositive, feedback12mNeutral, feedback12mNegative, partialResults}>}
* @throws {Error} - Wenn URL ungültig ist oder keine eBay-URL
*/
export async function parseViaExtensionExtended(url) {
// Validierung
if (!url || typeof url !== 'string') {
throw new Error("Invalid URL");
}
// Methode 1: chrome.runtime.sendMessage (externally_connectable)
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
try {
const extensionId = await getExtensionId();
if (!extensionId) {
throw new Error("Extension ID not available");
}
return new Promise((resolve, reject) => {
const message = {
action: "PARSE_ACCOUNT_EXTENDED",
url: url
};
const sendMessageCallback = (response) => {
if (chrome.runtime.lastError) {
const errorMsg = chrome.runtime.lastError.message || "Extension communication error";
reject(new Error(errorMsg));
return;
}
if (response && response.ok && response.data) {
resolve(response.data);
} else {
reject(new Error(response?.error || "Extension parsing failed"));
}
};
chrome.runtime.sendMessage(extensionId, message, sendMessageCallback);
// Timeout nach 90s (erhöht von 60s für 4 Tabs + Store-Suche)
setTimeout(() => {
reject(new Error("Extension timeout"));
}, 90000);
});
} catch (error) {
if (error.message && !error.message.includes("Extension")) {
throw error;
}
// Weiter zu Methode 2
}
}
// Methode 2: Window flag + postMessage (falls Content Script Relay vorhanden)
if (typeof window !== 'undefined' && window.__EBAY_EXTENSION__ === true) {
return new Promise((resolve, reject) => {
const messageId = `parse_extended_${Date.now()}_${Math.random()}`;
const responseHandler = (event) => {
if (event.data?.source !== 'eship-extension' || event.data?.messageId !== messageId) {
return;
}
window.removeEventListener('message', responseHandler);
if (event.data?.ok && event.data?.data) {
resolve(event.data.data);
} else {
reject(new Error(event.data?.error || "Extension parsing failed"));
}
};
window.addEventListener('message', responseHandler);
window.postMessage({
source: 'eship-webapp',
action: 'PARSE_ACCOUNT_EXTENDED',
url: url,
messageId: messageId
}, '*');
setTimeout(() => {
window.removeEventListener('message', responseHandler);
reject(new Error("Extension timeout"));
}, 60000);
});
}
// Keine Extension verfügbar
throw new Error("Extension not available");
}
/** /**
* Parst eine eBay-URL und extrahiert Account-Daten (Facade) * Parst eine eBay-URL und extrahiert Account-Daten (Facade)
* Versucht zuerst Extension-Pfad, fällt zurück auf Stub-Implementierung * Versucht zuerst Extension-Pfad, fällt zurück auf Stub-Implementierung
@@ -489,27 +626,14 @@ async function parseViaStub(url) {
export async function parseEbayAccount(url) { export async function parseEbayAccount(url) {
// Versuche IMMER Extension-Pfad zuerst (auch wenn Flag nicht gesetzt) // Versuche IMMER Extension-Pfad zuerst (auch wenn Flag nicht gesetzt)
// parseViaExtension prüft selbst, ob Extension verfügbar ist // parseViaExtension prüft selbst, ob Extension verfügbar ist
// #region agent log
const extAvailable = isExtensionAvailable();
const hasChromeRuntime = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage;
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:292',message:'parseEbayAccount: route decision',data:{extAvailable,hasChromeRuntime,url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
try { try {
// Versuche Extension zu nutzen (auch wenn Flag nicht gesetzt - parseViaExtension prüft selbst) // Versuche Extension zu nutzen (auch wenn Flag nicht gesetzt - parseViaExtension prüft selbst)
return await parseViaExtension(url); return await parseViaExtension(url);
} catch (e) { } catch (e) {
// Extension-Fehler: Fallback zu Stub // Extension-Fehler: Fallback zu Stub
console.warn("Extension parsing failed, falling back to stub:", e.message); console.warn("Extension parsing failed, falling back to stub:", e.message);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:299',message:'parseEbayAccount: extension error, using stub',data:{error:e.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
} }
// Fallback: Stub-Implementierung // Fallback: Stub-Implementierung
const stubResult = await parseViaStub(url); return await parseViaStub(url);
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'ebayParserService.js:304',message:'parseEbayAccount: stub result',data:{itemsSold:stubResult.stats?.itemsSold},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{});
// #endregion
return stubResult;
} }

View File

@@ -11,6 +11,46 @@ import { parseViaExtensionScanProducts } from "./ebayParserService";
const productsCollectionId = import.meta.env.VITE_APPWRITE_PRODUCTS_COLLECTION_ID || "products"; const productsCollectionId = import.meta.env.VITE_APPWRITE_PRODUCTS_COLLECTION_ID || "products";
/** Deutsche eBay-Zustandsbezeichnungen → product_condition Enum (längere zuerst) */
const CONDITION_DE_TO_ENUM = [
["neu mit etikett", "new_with_tags"],
["neu ohne etikett", "new_without_tags"],
["neu mit mängeln", "new_with_defects"],
["vom hersteller generalüberholt", "manufacturer_refurbished"],
["vom hersteller generalueberholt", "manufacturer_refurbished"],
["vom verkäufer generalüberholt", "seller_refurbished"],
["vom verkäufer generalueberholt", "seller_refurbished"],
["vom verkaeufer generalüberholt", "seller_refurbished"],
["vom verkaeufer generalueberholt", "seller_refurbished"],
["defekt oder unvollständig", "for_parts_or_not_working"],
["defekt oder unvollstaendig", "for_parts_or_not_working"],
["sehr gut", "very_good"],
["wie neu", "like_new"],
["ausstellerstück", "display_item"],
["ausstellerstueck", "display_item"],
["unbenutzt", "unused"],
["antik", "antique"],
["neu", "new"],
["gebraucht", "used"],
["gut", "good"],
["akzeptabel", "acceptable"],
];
/**
* Mappt deutsche eBay-Zustandsangabe auf product_condition Enum.
* @param {string|null|undefined} de - z.B. "Neu", "Gebraucht", "Neu mit Etikett"
* @returns {string|null} Enum-Wert oder null wenn nicht zuordenbar
*/
function mapConditionDeToEnum(de) {
if (de == null || typeof de !== "string") return null;
const n = de.trim().toLowerCase().replace(/\s+/g, " ");
if (!n) return null;
for (const [phrase, enumVal] of CONDITION_DE_TO_ENUM) {
if (n.includes(phrase)) return enumVal;
}
return null;
}
/** /**
* Lädt alle Products für einen bestimmten Account * Lädt alle Products für einen bestimmten Account
* @param {string} accountId - ID des Accounts (product_account_id) * @param {string} accountId - ID des Accounts (product_account_id)
@@ -68,6 +108,31 @@ export async function listProductsByAccount(accountId, options = {}) {
} }
} }
const EXISTING_PRODUCTS_PAGE_SIZE = 100;
/**
* Lädt alle Products eines Accounts für Duplikat-Prüfung (paginiert, Appwrite-Limit 100/Request).
* @param {string} accountId
* @returns {Promise<Array>}
*/
async function fetchAllProductsByAccount(accountId) {
const all = [];
let offset = 0;
let hasMore = true;
while (hasMore) {
const page = await listProductsByAccount(accountId, {
limit: EXISTING_PRODUCTS_PAGE_SIZE,
offset,
orderBy: "$createdAt",
orderType: "asc",
});
all.push(...page);
hasMore = page.length === EXISTING_PRODUCTS_PAGE_SIZE;
offset += page.length;
}
return all;
}
/** /**
* Lädt ein einzelnes Product nach ID * Lädt ein einzelnes Product nach ID
* @param {string} productId - ID des Products * @param {string} productId - ID des Products
@@ -118,33 +183,97 @@ export async function scanProductsForAccount(accountId) {
} }
// 2. Extension aufrufen: scanne Produkte // 2. Extension aufrufen: scanne Produkte
const items = await parseViaExtensionScanProducts(account.account_url, accountId); let items;
try {
items = await parseViaExtensionScanProducts(account.account_url, accountId);
} catch (extensionError) {
// Extension-Fehler: Parsing failed
console.error("Extension-Fehler beim Scannen:", extensionError);
// Erstelle detaillierte Fehlermeldung mit meta-Informationen
const meta = extensionError.meta || {};
let errorMessage = extensionError.message || "Parsing failed";
// Füge meta-Details hinzu, falls vorhanden
if (meta.pageType) {
errorMessage += ` (Seitentyp: ${meta.pageType})`;
}
if (meta.reason) {
errorMessage += ` (Grund: ${meta.reason})`;
}
if (meta.finalUrl) {
errorMessage += ` (URL: ${meta.finalUrl})`;
}
const detailedError = new Error(`Extension-Fehler: ${errorMessage}`);
detailedError.meta = meta;
throw detailedError;
}
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'productsService.js:scanProductsForAccount',message:'items from extension',data:{itemsCount:items?.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H4'})}).catch(()=>{});
// #endregion
// 3. Response-Items zu Product-Schema mappen // 3. Response-Items zu Product-Schema mappen
const mappedProducts = items.map(item => { const mappedProducts = items.map(item => {
// Validiere, dass platformProductId vorhanden ist
if (!item.platformProductId) { if (!item.platformProductId) {
console.warn("Item ohne platformProductId übersprungen:", item); console.warn("Item ohne platformProductId übersprungen:", item);
return null; return null;
} }
const id = String(item.platformProductId).trim();
if (id === "123456" || id.length < 10) {
console.warn("Item mit Platzhalter-/ungültiger ID übersprungen (eBay-IDs 1012 Ziffern):", id);
return null;
}
return { return {
product_platform_product_id: item.platformProductId, product_platform_product_id: item.platformProductId,
product_title: item.title || "", product_title: item.title || "",
product_price: item.price ?? undefined, // undefined statt null für Appwrite product_price: item.price ?? undefined,
product_currency: item.currency ?? undefined, // auto-fill from market if undefined product_currency: item.currency ?? undefined,
product_url: item.url || "", product_url: item.url || "",
product_status: item.status ?? "unknown", product_status: item.status ?? "unknown",
product_category: item.category ?? "unknown", product_category: String(item.category ?? "unknown").slice(0, 255),
product_condition: item.condition ?? "unknown" product_condition: mapConditionDeToEnum(item.condition) ?? "used",
product_quantity_sold: item.quantitySold ?? undefined,
product_quantity_available: item.quantityAvailable ?? undefined,
product_watch_count: item.watchCount ?? undefined,
product_in_carts_count: item.inCartsCount ?? undefined
}; };
}).filter(Boolean); // Entferne null-Einträge }).filter(Boolean); // Entferne null-Einträge
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'productsService.js:scanProductsForAccount',message:'mapped products',data:{mappedCount:mappedProducts.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
// #endregion
// 4. upsertProductsForAccount aufrufen // 4. upsertProductsForAccount aufrufen
const result = await upsertProductsForAccount(accountId, mappedProducts); let result;
try {
result = await upsertProductsForAccount(accountId, mappedProducts);
} catch (dbError) {
// Datenbank-Fehler: Collection oder Attribute fehlen
console.error("Datenbank-Fehler beim Speichern:", dbError);
// Prüfe auf spezifische Datenbank-Fehler
if (dbError.code === 404 || dbError.type === 'collection_not_found') {
throw new Error("Datenbank-Fehler: Products-Collection existiert nicht. Bitte Datenbank-Schema einrichten.");
}
if (dbError.code === 400 || dbError.message?.includes('attribute')) {
throw new Error(`Datenbank-Fehler: Ein Attribut fehlt oder ist ungültig. Details: ${dbError.message}`);
}
if (dbError.code === 401 || dbError.type === 'permission_denied') {
throw new Error("Datenbank-Fehler: Keine Berechtigung zum Speichern von Produkten. Bitte Berechtigungen prüfen.");
}
// Generischer Datenbank-Fehler
throw new Error(`Datenbank-Fehler beim Speichern: ${dbError.message || "Unbekannter Fehler"}`);
}
// 5. account_last_scan_at aktualisieren // 5. account_last_scan_at aktualisieren
try {
await updateAccountLastScanAt(accountId, new Date().toISOString()); await updateAccountLastScanAt(accountId, new Date().toISOString());
} catch (updateError) {
// Nicht kritisch, nur loggen
console.warn("Fehler beim Aktualisieren von account_last_scan_at:", updateError);
}
// 6. Return { created, updated } // 6. Return { created, updated }
return result; return result;
@@ -186,11 +315,11 @@ export async function upsertProductsForAccount(accountId, products) {
const currency = deriveCurrencyFromMarket(market); const currency = deriveCurrencyFromMarket(market);
const platform = "ebay"; // Enum-Werte sind lowercase gemäß Fehlermeldung: [amazon], [ebay] const platform = "ebay"; // Enum-Werte sind lowercase gemäß Fehlermeldung: [amazon], [ebay]
// Lade bestehende Produkte für Duplikat-Prüfung // Lade alle bestehenden Produkte (paginiert, Appwrite max 100/Request)
const existingProducts = await listProductsByAccount(accountId, { const existingProducts = await fetchAllProductsByAccount(accountId);
limit: 1000, // #region agent log
offset: 0, fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'productsService.js:upsertProductsForAccount',message:'existing products loaded',data:{productsToUpsert:products.length,existingCount:existingProducts.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
}); // #endregion
// Erstelle Map von platform_product_id -> bestehendes Produkt // Erstelle Map von platform_product_id -> bestehendes Produkt
const existingProductsMap = new Map(); const existingProductsMap = new Map();
@@ -234,22 +363,43 @@ export async function upsertProductsForAccount(accountId, products) {
); );
updated++; updated++;
} else { } else {
// Erstelle neues Produkt // Erstelle neues Produkt (product_first_fullscan_at = heutiges Datum)
await databases.createDocument( await databases.createDocument(
databaseId, databaseId,
productsCollectionId, productsCollectionId,
ID.unique(), ID.unique(),
fullProductData {
...fullProductData,
product_first_fullscan_at: new Date().toISOString()
}
); );
created++; created++;
} }
} catch (e) { } catch (e) {
console.error(`Fehler beim Upsert des Produkts "${platformProductId}":`, e); console.error(`Fehler beim Upsert des Produkts "${platformProductId}":`, e);
// Weiter mit nächstem Produkt // #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'productsService.js:upsertProductsForAccount',message:'upsert error',data:{platformProductId,code:e?.code,type:e?.type,message:String(e?.message||'')},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H3'})}).catch(()=>{});
// #endregion
// Prüfe auf spezifische Datenbank-Fehler
if (e.code === 404 || e.type === 'collection_not_found' || e.type === 'document_not_found') {
throw new Error("Datenbank-Fehler: Products-Collection oder Dokument existiert nicht. Bitte Datenbank-Schema einrichten.");
}
if (e.code === 400 || e.message?.includes('attribute') || e.message?.includes('required')) {
throw new Error(`Datenbank-Fehler: Ein Attribut fehlt oder ist ungültig. Produkt: ${platformProductId}, Details: ${e.message}`);
}
if (e.code === 401 || e.type === 'permission_denied') {
throw new Error("Datenbank-Fehler: Keine Berechtigung zum Speichern von Produkten. Bitte Berechtigungen prüfen.");
}
// Bei anderen Fehlern: Weiter mit nächstem Produkt (nicht kritisch)
console.warn(`Überspringe Produkt "${platformProductId}" aufgrund von Fehler:`, e.message);
continue; continue;
} }
} }
// #region agent log
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'productsService.js:upsertProductsForAccount',message:'upsert result',data:{created,updated},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H1'})}).catch(()=>{});
// #endregion
return { created, updated }; return { created, updated };
} catch (e) { } catch (e) {
console.error("Fehler beim Upsert der Produkte:", e); console.error("Fehler beim Upsert der Produkte:", e);

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -0,0 +1,268 @@
# Products Collection Schema
Diese Datei beschreibt alle Attribute, die in der `products` Collection benötigt werden, damit das Programm funktioniert.
## Collection: `products`
**Collection ID:** `products` (oder über `VITE_APPWRITE_PRODUCTS_COLLECTION_ID` konfiguriert)
## Erforderliche Attribute
### 1. `product_account_id` (String, Required)
- **Typ:** String
- **Required:** Ja
- **Verwendung:**
- Filterung nach Account (Query.equal)
- Verknüpfung zu Accounts-Collection
- **Beispiel:** `"account_123"`
### 2. `product_platform` (Enum, Required)
- **Typ:** Enum
- **Required:** Ja
- **Werte:** `["amazon", "ebay"]`
- **Verwendung:**
- Speichert die Plattform (aktuell nur "ebay" wird verwendet)
- **Beispiel:** `"ebay"`
### 3. `product_platform_market` (String, Required)
- **Typ:** String
- **Required:** Ja
- **Verwendung:**
- Speichert den Marktplatz (z.B. "DE", "US", "UK")
- Wird aus Account abgeleitet
- **Beispiel:** `"DE"`, `"US"`, `"UK"`
### 4. `product_platform_product_id` (String, Required, Unique)
- **Typ:** String
- **Required:** Ja
- **Unique:** Ja (für Duplikat-Prüfung)
- **Verwendung:**
- Eindeutige Produkt-ID von der Plattform (z.B. eBay Item-ID)
- Wird für Duplikat-Prüfung verwendet
- Wird für Mapping zwischen Extension und Datenbank verwendet
- **Beispiel:** `"123456789"` (eBay Item-ID)
### 5. `product_title` (String, Optional)
- **Typ:** String
- **Required:** Nein
- **Verwendung:**
- Produkttitel
- Wird für Anzeige in UI verwendet
- Wird für Suchfilter verwendet (client-side)
- **Beispiel:** `"iPhone 13 Pro Max 256GB"`
### 6. `product_price` (Float, Optional)
- **Typ:** Float
- **Required:** Nein
- **Verwendung:**
- Produktpreis
- Wird für KPI-Berechnungen verwendet (Durchschnittspreis)
- Wird für Price Spread Insights verwendet
- **Beispiel:** `99.99`
### 7. `product_currency` (String, Optional)
- **Typ:** String
- **Required:** Nein
- **Verwendung:**
- Währung (z.B. "EUR", "USD", "GBP")
- Wird aus Market abgeleitet, falls nicht vorhanden
- **Beispiel:** `"EUR"`, `"USD"`, `"GBP"`
### 8. `product_url` (String, Optional)
- **Typ:** String
- **Required:** Nein
- **Verwendung:**
- URL zum Produkt auf der Plattform
- **Beispiel:** `"https://www.ebay.de/itm/123456789"`
### 9. `product_status` (Enum, Optional)
- **Typ:** Enum
- **Required:** Nein
- **Werte:** `["active", "ended", "unknown"]`
- **Verwendung:**
- Status des Produkts
- Wird für Filterung verwendet (Overview KPIs, Products Page)
- Wird für Status-Filter in UI verwendet
- **Default:** `"unknown"`
- **Beispiel:** `"active"`, `"ended"`, `"unknown"`
### 10. `product_category` (String, Optional)
- **Typ:** String
- **Required:** Nein
- **Verwendung:**
- Produktkategorie
- Wird für Category Share Insights verwendet
- **Default:** `"unknown"`
- **Beispiel:** `"Electronics"`, `"Clothing"`
### 11. `product_condition` (String, Optional)
- **Typ:** String
- **Required:** Nein
- **Verwendung:**
- Zustand des Produkts (z.B. "New", "Used")
- **Default:** `"unknown"`
- **Beispiel:** `"New"`, `"Used"`, `"Refurbished"`
## Standard-Appwrite-Felder
Diese Felder werden automatisch von Appwrite bereitgestellt:
- **`$id`** (String, Required) - Eindeutige Dokument-ID
- **`$createdAt`** (DateTime, Required) - Erstellungsdatum (wird für Sortierung verwendet)
- **`$updatedAt`** (DateTime, Required) - Aktualisierungsdatum
## Indexes (Empfohlen)
Für bessere Performance sollten folgende Indexes erstellt werden:
1. **Index auf `product_account_id`** (für Filterung)
- Attribute: `product_account_id`
- Typ: Key
2. **Index auf `product_platform_product_id`** (für Duplikat-Prüfung)
- Attribute: `product_platform_product_id`
- Typ: Unique Key
3. **Index auf `product_account_id` + `$createdAt`** (für Sortierung)
- Attribute: `product_account_id`, `$createdAt`
- Typ: Composite
## Berechtigungen
Die Collection benötigt folgende Berechtigungen:
- **Read:** Authenticated Users
- **Create:** Authenticated Users
- **Update:** Authenticated Users
- **Delete:** Authenticated Users (optional, falls Löschen benötigt wird)
## Appwrite CLI Befehle zum Erstellen
```bash
# Collection erstellen
appwrite databases createCollection \
--database-id eship-db \
--collection-id products \
--name "Products"
# Attribute erstellen
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_account_id \
--required true \
--size 255
appwrite databases createEnumAttribute \
--database-id eship-db \
--collection-id products \
--key product_platform \
--elements amazon ebay \
--required true
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_platform_market \
--required true \
--size 10
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_platform_product_id \
--required true \
--size 255
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_title \
--required false \
--size 500
appwrite databases createFloatAttribute \
--database-id eship-db \
--collection-id products \
--key product_price \
--required false
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_currency \
--required false \
--size 10
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_url \
--required false \
--size 1000
appwrite databases createEnumAttribute \
--database-id eship-db \
--collection-id products \
--key product_status \
--elements active ended unknown \
--required false
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_category \
--required false \
--size 255
appwrite databases createStringAttribute \
--database-id eship-db \
--collection-id products \
--key product_condition \
--required false \
--size 100
# Indexes erstellen
appwrite databases createIndex \
--database-id eship-db \
--collection-id products \
--key idx_account_id \
--type key \
--attributes product_account_id
appwrite databases createIndex \
--database-id eship-db \
--collection-id products \
--key idx_platform_product_id \
--type unique \
--attributes product_platform_product_id
appwrite databases createIndex \
--database-id eship-db \
--collection-id products \
--key idx_account_created \
--type key \
--attributes product_account_id $createdAt
```
## Zusammenfassung
**Erforderliche Attribute (Required):**
1. `product_account_id` (String)
2. `product_platform` (Enum: ["amazon", "ebay"])
3. `product_platform_market` (String)
4. `product_platform_product_id` (String, Unique)
**Optionale Attribute:**
5. `product_title` (String)
6. `product_price` (Float)
7. `product_currency` (String)
8. `product_url` (String)
9. `product_status` (Enum: ["active", "ended", "unknown"])
10. `product_category` (String)
11. `product_condition` (String)
**Standard-Felder (automatisch):**
- `$id` (String)
- `$createdAt` (DateTime)
- `$updatedAt` (DateTime)

View File

@@ -0,0 +1,78 @@
# Products Scan Fehlerbehebung
## Fehler: "Parsing failed (unknown)"
Dieser Fehler kann zwei Ursachen haben:
### 1. Extension-Fehler (Parsing failed)
**Symptome:**
- Fehlermeldung: `Extension-Fehler: Parsing failed (unknown)`
- Die Extension kann keine Produkte auf der eBay-Seite finden
**Mögliche Ursachen:**
- Die Account-URL zeigt nicht auf eine Seite mit Produkt-Listings
- Die eBay-Seite hat sich geändert und die Extension-Selektoren funktionieren nicht mehr
- Die Seite ist noch nicht vollständig geladen
**Lösung:**
- Stelle sicher, dass die Account-URL auf eine Seite mit Produkt-Listings zeigt (z.B. `/str/` Storefront oder `/usr/` Seller Profile mit Items)
- Versuche die Extension neu zu laden
- Prüfe die Browser-Konsole für weitere Details
### 2. Datenbank-Fehler (Collection/Attribute fehlt)
**Symptome:**
- Fehlermeldung: `Datenbank-Fehler: Products-Collection existiert nicht` oder
- Fehlermeldung: `Datenbank-Fehler: Ein Attribut fehlt oder ist ungültig`
**Mögliche Ursachen:**
- Die `products` Collection wurde noch nicht erstellt
- Die Collection existiert, aber es fehlen erforderliche Attribute
- Die Berechtigungen für die Collection sind nicht korrekt konfiguriert
**Lösung:**
1. **Prüfe ob die Collection existiert:**
```bash
appwrite databases listCollections --database-id eship-db
```
2. **Erstelle die Collection falls sie fehlt:**
- Öffne die Appwrite-Konsole
- Navigiere zu Databases → eship-db → Collections
- Erstelle eine neue Collection mit ID: `products`
3. **Erstelle die erforderlichen Attribute:**
Die Collection benötigt folgende Attribute:
- `product_account_id` (string, required)
- `product_platform` (enum: ["amazon", "ebay"], required)
- `product_platform_market` (string, required)
- `product_platform_product_id` (string, required, unique)
- `product_title` (string)
- `product_price` (float)
- `product_currency` (string)
- `product_url` (string)
- `product_status` (enum: ["active", "ended", "unknown"])
- `product_category` (string)
- `product_condition` (string)
4. **Prüfe die Berechtigungen:**
- Die Collection muss Lese- und Schreibrechte für authentifizierte Benutzer haben
## Fehler: "ERR_CONNECTION_REFUSED" auf Port 7242
Dieser Fehler ist **nicht kritisch** und kann ignoriert werden. Es handelt sich um einen Debug-Logging-Versuch, der fehlschlägt, weil kein Server auf Port 7242 läuft. Dies hat keinen Einfluss auf die Funktionalität.
## Diagnose-Schritte
1. **Prüfe die Browser-Konsole** für detaillierte Fehlermeldungen
2. **Prüfe ob die Extension geladen ist:**
- Öffne `chrome://extensions/`
- Stelle sicher, dass die Extension aktiviert ist
3. **Prüfe die Datenbank-Struktur:**
- Verwende die Appwrite-Konsole oder CLI
- Stelle sicher, dass die `products` Collection existiert
4. **Teste die Account-URL:**
- Öffne die Account-URL manuell im Browser
- Stelle sicher, dass Produkt-Listings sichtbar sind

View File

@@ -0,0 +1,258 @@
# Appwrite schema bootstrap (PowerShell) fuer Appwrite Server 1.8.x
# Ziel: Alte Struktur behalten (kompatibel) + neue "upright v1" Felder integrieren.
#
# Tabellen: users, accounts, products, product_details
#
# Prereqs:
# appwrite login
# appwrite init project
#
# Run:
# pwsh .\appwrite_schema_upright_v1.ps1 -DatabaseId "YOUR_DATABASE_ID"
param(
[Parameter(Mandatory = $true)]
[string]$DatabaseId,
# Optional: setze auf $true, wenn du Fulltext-Indizes wirklich nutzen willst.
[bool]$EnableFulltextIndexes = $false
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ---------------- CONFIG ----------------
$T_USERS = "users"
$T_ACCOUNTS = "accounts"
$T_PRODUCTS = "products"
$T_PRODUCT_DETAILS = "product_details"
# Minimal offene Defaults (spaeter ggf. locken)
$PERMS_ANY_CRUD = @('create(any)','read(any)','update(any)','delete(any)')
# ---------------- HELPERS ----------------
function Try-Cmd {
param(
[Parameter(Mandatory = $true)]
[string[]]$Args
)
$cmd = "appwrite " + ($Args -join " ")
Write-Host ("+ " + $cmd)
try {
& appwrite @Args | Out-Host
} catch {
Write-Host (" (ignored error) " + $_.Exception.Message)
}
}
function Create-Table {
param(
[Parameter(Mandatory = $true)][string]$TableId,
[Parameter(Mandatory = $true)][string]$Name
)
# Note: Permissions für TablesDB werden anders verwaltet (optional)
# Hier erstellen wir die Tabelle ohne Permissions
$argsList = @(
"tables-db","create-table",
"--database-id",$DatabaseId,
"--table-id",$TableId,
"--name",$Name,
"--row-security","false"
)
Try-Cmd $argsList
}
# ---------------- CREATE TABLES ----------------
Create-Table -TableId $T_USERS -Name "users"
Create-Table -TableId $T_ACCOUNTS -Name "accounts"
Create-Table -TableId $T_PRODUCTS -Name "products"
Create-Table -TableId $T_PRODUCT_DETAILS -Name "product_details"
# ---------------- USERS COLUMNS ----------------
# Legacy kompatibel: user_note
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_USERS,"--key","user_note","--size","255","--required","false","--array","false")
# Upright v1: created/updated timestamps (optional)
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_USERS,"--key","user_created_at","--required","false","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_USERS,"--key","user_updated_at","--required","false","--array","false")
# Optional index: user_created_at (wenn du sorting/listing brauchst)
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_USERS,"--key","users_by_created_at","--type","key","--columns","user_created_at")
# ---------------- ACCOUNTS COLUMNS ----------------
# Upright v1:
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_owner_user_id","--size","64","--required","false","--array","false")
# Legacy: account_managed (alte scripts)
Try-Cmd @("tables-db","create-boolean-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_managed","--required","false","--array","false")
# Upright v1: account_team (true = managed/team, false = scanned)
Try-Cmd @("tables-db","create-boolean-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_team","--required","true","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_platform","--elements",'["amazon","ebay"]',"--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_platform_account_id","--size","255","--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_platform_market","--size","32","--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_shop_name","--size","255","--required","false","--array","false")
Try-Cmd @("tables-db","create-url-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_url","--required","false","--array","false")
# Statistik: Anzahl verkaufter Items
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_sells","--required","false","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_status","--elements",'["active","unknown","disabled"]',"--required","false","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_created_at","--required","false","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","account_updated_at","--required","false","--array","false")
# ---------------- ACCOUNTS INDEXES ----------------
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","uniq_plat_mkt_accountid","--type","unique","--columns","account_platform","account_platform_market","account_platform_account_id")
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","accounts_by_owner_user","--type","key","--columns","account_owner_user_id")
# Optional: account_team index
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_ACCOUNTS,"--key","accounts_by_team_flag","--type","key","--columns","account_team")
# ---------------- PRODUCTS COLUMNS ----------------
# Stable basis (legacy + upright v1)
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_account_id","--size","64","--required","true","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_platform","--elements",'["amazon","ebay"]',"--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_platform_market","--size","32","--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_platform_product_id","--size","255","--required","true","--array","false")
Try-Cmd @("tables-db","create-url-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_url","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_title","--size","1024","--required","true","--array","false")
# Legacy: product_category
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_category","--size","255","--required","false","--array","false")
# Condition/status (upright v1 erweitert unknown)
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_condition","--elements",'["new","used_like_new","used_good","used_ok","parts","unknown"]',"--required","false","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_status","--elements",'["active","ended","unknown"]',"--required","false","--array","false")
# Volatile (Sparscan) - neue Felder
Try-Cmd @("tables-db","create-float-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_price","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_currency","--size","8","--required","false","--array","false")
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_quantity_available","--required","false","--array","false")
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_quantity_sold","--required","false","--array","false")
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_watch_count","--required","false","--array","false")
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_in_carts_count","--required","false","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_last_seen_at","--required","false","--array","false")
# Legacy kompatibel: product_quantity (alt)
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_quantity","--required","false","--array","false")
# Scan-Steuerung
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_first_fullscan_at","--required","false","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_last_fullscan_at","--required","false","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_last_sparscan_at","--required","false","--array","false")
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","product_details_version","--required","false","--array","false")
# ---------------- PRODUCTS INDEXES ----------------
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","products_by_account","--type","key","--columns","product_account_id")
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","uniq_acct_platformprod_id","--type","unique","--columns","product_account_id","product_platform_product_id")
# Optional indices
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","products_by_platform_market","--type","key","--columns","product_platform","product_platform_market")
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","products_by_status","--type","key","--columns","product_status")
if ($EnableFulltextIndexes) {
# Hinweis: Fulltext kann je nach Appwrite/CLI Build abweichen. Falls es bei dir anders heisst:
# appwrite tables-db create-index --help
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCTS,"--key","products_fulltext_title","--type","fulltext","--columns","product_title")
}
# ---------------- PRODUCT_DETAILS COLUMNS ----------------
# Upright v1 minimal (neu)
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_id","--size","64","--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_category_path","--size","2048","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_brand","--size","255","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_mpn","--size","64","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_gtin","--size","32","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_description_text","--size","8192","--required","false","--array","false")
# Array of image urls
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_image_urls","--size","2048","--required","false","--array","true")
# JSON-as-string
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_item_specifics_json","--size","8192","--required","false","--array","false")
Try-Cmd @("tables-db","create-boolean-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_has_variations","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_variations_json","--size","8192","--required","false","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","details_last_updated_at","--required","false","--array","false")
# Legacy detail schema (alt) - behalten fuer Kompatibilitaet
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_product_id","--size","64","--required","false","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_platform","--elements",'["amazon","ebay"]',"--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_platform_market","--size","32","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_gtin","--size","32","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_ean","--size","32","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_upc","--size","32","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_isbn","--size","32","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_mpn","--size","64","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_amazon_asin","--size","32","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_ebay_epid","--size","64","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_brand","--size","255","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_manufacturer","--size","255","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_model_name","--size","255","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_model_number","--size","255","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_short_description","--size","2048","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_long_description","--size","8192","--required","false","--array","false")
for ($i = 1; $i -le 8; $i++) {
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key",("product_detail_bullet_" + $i),"--size","512","--required","false","--array","false")
}
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_search_terms","--size","1024","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_color","--size","128","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_size","--size","128","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_material","--size","128","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_pattern","--size","128","--required","false","--array","false")
Try-Cmd @("tables-db","create-float-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_length","--required","false","--array","false")
Try-Cmd @("tables-db","create-float-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_width","--required","false","--array","false")
Try-Cmd @("tables-db","create-float-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_height","--required","false","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_dimension_unit","--elements",'["mm","cm","m","in"]',"--required","false","--array","false")
Try-Cmd @("tables-db","create-float-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_weight","--required","false","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_weight_unit","--elements",'["g","kg","oz","lb"]',"--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_country_of_origin","--size","64","--required","false","--array","false")
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","product_detail_package_quantity","--required","false","--array","false")
# ---------------- PRODUCT_DETAILS INDEXES ----------------
# Upright v1 unique 1:1
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","prod_details_uniq_product_id","--type","unique","--columns","product_id")
# Legacy unique (optional, kann leer bleiben)
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","prod_details_legacy_prod_id","--type","unique","--columns","product_detail_product_id")
# Legacy: by platform (wenn du es brauchst)
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","prod_details_by_platform","--type","key","--columns","product_detail_platform")
if ($EnableFulltextIndexes) {
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_PRODUCT_DETAILS,"--key","prod_details_ft_description","--type","fulltext","--columns","details_description_text")
}
Write-Host "Done. Ensured 4 tables: users, accounts, products, product_details"
Write-Host ""
Write-Host "App logic reminder (upright v1):"
Write-Host "- If account_team == true => account_owner_user_id must be set"
Write-Host "- If account_team == false => account_owner_user_id must be null"
Write-Host ""
Write-Host "Scan rules reminder:"
Write-Host "- If account has 0 products => fullscan (products + product_details)"
Write-Host "- Else sparscan updates volatile fields in products (price/qty/watch/carts/status/last_seen)"
Write-Host "- Fullscan a product if product_details missing OR product_last_fullscan_at older than your threshold (e.g. 30 days)"

View File

@@ -0,0 +1,86 @@
# Appwrite schema script für account_metrics Collection
# Erstellt die account_metrics Collection mit allen Attributen und Indexes
#
# Prereqs:
# appwrite login
# appwrite init project
#
# Run:
# pwsh .\create-account-metrics-collection.ps1 -DatabaseId "YOUR_DATABASE_ID"
param(
[Parameter(Mandatory = $true)]
[string]$DatabaseId
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ---------------- CONFIG ----------------
$T_ACCOUNT_METRICS = "account_metrics"
# Minimal offene Defaults
$PERMS_ANY_CRUD = @('create(any)','read(any)','update(any)','delete(any)')
# ---------------- HELPERS ----------------
function Try-Cmd {
param(
[Parameter(Mandatory = $true)]
[string[]]$Args
)
$cmd = "appwrite " + ($Args -join " ")
Write-Host ("+ " + $cmd)
try {
& appwrite @Args | Out-Host
} catch {
Write-Host (" (ignored error) " + $_.Exception.Message)
}
}
function Create-Table {
param(
[Parameter(Mandatory = $true)][string]$TableId,
[Parameter(Mandatory = $true)][string]$Name
)
$argsList = @(
"tables-db","create-table",
"--database-id",$DatabaseId,
"--table-id",$TableId,
"--name",$Name,
"--row-security","false"
)
Try-Cmd $argsList
}
# ---------------- CREATE TABLE ----------------
Create-Table -TableId $T_ACCOUNT_METRICS -Name "account_metrics"
# ---------------- ACCOUNT_METRICS COLUMNS ----------------
# Required attributes
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_account_id","--size","64","--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_date","--size","10","--required","true","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_month","--size","7","--required","true","--array","false")
# Refresh layer attributes
Try-Cmd @("tables-db","create-boolean-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_refreshed","--required","true","--array","false")
Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_refresh_status","--elements",'["success","failed"]',"--required","true","--array","false")
Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_refreshed_at","--required","false","--array","false")
# Sales layer attributes
Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_sales_count","--required","false","--array","false")
Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_sales_bucket","--size","10","--required","false","--array","false")
# ---------------- ACCOUNT_METRICS INDEXES ----------------
# Unique index: ensures one doc per account per day
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","uniq_account_date","--type","unique","--columns","account_metrics_account_id","account_metrics_date")
# Index for month queries
Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","idx_account_month","--type","key","--columns","account_metrics_account_id","account_metrics_month")
Write-Host ""
Write-Host "account_metrics Collection erstellt!"
Write-Host ""

View File

@@ -0,0 +1,328 @@
# PowerShell Script zum Erstellen der Products Collection
# Erstellt die Collection mit allen erforderlichen Attributen
$ErrorActionPreference = "Continue"
Write-Host "=== Products Collection Setup ===" -ForegroundColor Cyan
Write-Host ""
# Konfiguration
$DATABASE_ID = "eship-db"
$COLLECTION_ID = "products"
$COLLECTION_NAME = "Products"
# 1. Prüfe Login-Status
Write-Host "1. Prüfe Appwrite Login-Status..." -ForegroundColor Yellow
$loginCheck = appwrite databases list 2>&1
if ($LASTEXITCODE -ne 0 -and $loginCheck -like "*Session not found*") {
Write-Host " [WARNUNG] Nicht eingeloggt. Bitte zuerst einloggen:" -ForegroundColor Red
Write-Host " appwrite login" -ForegroundColor White
Write-Host ""
Write-Host " Führe diesen Befehl jetzt aus..." -ForegroundColor Yellow
appwrite login
Write-Host ""
}
# 2. Erstelle Collection
Write-Host "2. Erstelle Collection '$COLLECTION_ID'..." -ForegroundColor Yellow
$createCollection = appwrite databases createCollection `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--name $COLLECTION_NAME `
--document-security false `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Collection '$COLLECTION_ID' erfolgreich erstellt!" -ForegroundColor Green
} else {
if ($createCollection -like "*already exists*" -or $createCollection -like "*duplicate*") {
Write-Host " [INFO] Collection '$COLLECTION_ID' existiert bereits." -ForegroundColor Yellow
} else {
Write-Host " [FEHLER] Fehler beim Erstellen der Collection:" -ForegroundColor Red
Write-Host " $createCollection" -ForegroundColor Red
exit 1
}
}
Write-Host ""
# 3. Erstelle Attribute
Write-Host "3. Erstelle Attribute..." -ForegroundColor Yellow
# 3.1 product_account_id (String, Required)
Write-Host " 3.1 product_account_id..." -ForegroundColor Gray
$attr1 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_account_id `
--required true `
--size 255 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_account_id erstellt" -ForegroundColor Green
} else {
if ($attr1 -like "*already exists*" -or $attr1 -like "*duplicate*") {
Write-Host " [INFO] product_account_id existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr1" -ForegroundColor Yellow
}
}
# 3.2 product_platform (Enum, Required)
Write-Host " 3.2 product_platform..." -ForegroundColor Gray
$attr2 = appwrite databases createEnumAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_platform `
--elements amazon ebay `
--required true `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_platform erstellt" -ForegroundColor Green
} else {
if ($attr2 -like "*already exists*" -or $attr2 -like "*duplicate*") {
Write-Host " [INFO] product_platform existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr2" -ForegroundColor Yellow
}
}
# 3.3 product_platform_market (String, Required)
Write-Host " 3.3 product_platform_market..." -ForegroundColor Gray
$attr3 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_platform_market `
--required true `
--size 10 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_platform_market erstellt" -ForegroundColor Green
} else {
if ($attr3 -like "*already exists*" -or $attr3 -like "*duplicate*") {
Write-Host " [INFO] product_platform_market existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr3" -ForegroundColor Yellow
}
}
# 3.4 product_platform_product_id (String, Required)
Write-Host " 3.4 product_platform_product_id..." -ForegroundColor Gray
$attr4 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_platform_product_id `
--required true `
--size 255 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_platform_product_id erstellt" -ForegroundColor Green
} else {
if ($attr4 -like "*already exists*" -or $attr4 -like "*duplicate*") {
Write-Host " [INFO] product_platform_product_id existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr4" -ForegroundColor Yellow
}
}
# 3.5 product_title (String, Optional)
Write-Host " 3.5 product_title..." -ForegroundColor Gray
$attr5 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_title `
--required false `
--size 500 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_title erstellt" -ForegroundColor Green
} else {
if ($attr5 -like "*already exists*" -or $attr5 -like "*duplicate*") {
Write-Host " [INFO] product_title existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr5" -ForegroundColor Yellow
}
}
# 3.6 product_price (Float, Optional)
Write-Host " 3.6 product_price..." -ForegroundColor Gray
$attr6 = appwrite databases createFloatAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_price `
--required false `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_price erstellt" -ForegroundColor Green
} else {
if ($attr6 -like "*already exists*" -or $attr6 -like "*duplicate*") {
Write-Host " [INFO] product_price existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr6" -ForegroundColor Yellow
}
}
# 3.7 product_currency (String, Optional)
Write-Host " 3.7 product_currency..." -ForegroundColor Gray
$attr7 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_currency `
--required false `
--size 10 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_currency erstellt" -ForegroundColor Green
} else {
if ($attr7 -like "*already exists*" -or $attr7 -like "*duplicate*") {
Write-Host " [INFO] product_currency existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr7" -ForegroundColor Yellow
}
}
# 3.8 product_url (String, Optional)
Write-Host " 3.8 product_url..." -ForegroundColor Gray
$attr8 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_url `
--required false `
--size 1000 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_url erstellt" -ForegroundColor Green
} else {
if ($attr8 -like "*already exists*" -or $attr8 -like "*duplicate*") {
Write-Host " [INFO] product_url existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr8" -ForegroundColor Yellow
}
}
# 3.9 product_status (Enum, Optional)
Write-Host " 3.9 product_status..." -ForegroundColor Gray
$attr9 = appwrite databases createEnumAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_status `
--elements active ended unknown `
--required false `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_status erstellt" -ForegroundColor Green
} else {
if ($attr9 -like "*already exists*" -or $attr9 -like "*duplicate*") {
Write-Host " [INFO] product_status existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr9" -ForegroundColor Yellow
}
}
# 3.10 product_category (String, Optional)
Write-Host " 3.10 product_category..." -ForegroundColor Gray
$attr10 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_category `
--required false `
--size 255 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_category erstellt" -ForegroundColor Green
} else {
if ($attr10 -like "*already exists*" -or $attr10 -like "*duplicate*") {
Write-Host " [INFO] product_category existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr10" -ForegroundColor Yellow
}
}
# 3.11 product_condition (String, Optional)
Write-Host " 3.11 product_condition..." -ForegroundColor Gray
$attr11 = appwrite databases createStringAttribute `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key product_condition `
--required false `
--size 100 `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] product_condition erstellt" -ForegroundColor Green
} else {
if ($attr11 -like "*already exists*" -or $attr11 -like "*duplicate*") {
Write-Host " [INFO] product_condition existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $attr11" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "4. Erstelle Indexes..." -ForegroundColor Yellow
# 4.1 Index auf product_account_id
Write-Host " 4.1 Index auf product_account_id..." -ForegroundColor Gray
$idx1 = appwrite databases createIndex `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key idx_account_id `
--type key `
--attributes product_account_id `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Index idx_account_id erstellt" -ForegroundColor Green
} else {
if ($idx1 -like "*already exists*" -or $idx1 -like "*duplicate*") {
Write-Host " [INFO] Index idx_account_id existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $idx1" -ForegroundColor Yellow
}
}
# 4.2 Unique Index auf product_platform_product_id
Write-Host " 4.2 Unique Index auf product_platform_product_id..." -ForegroundColor Gray
$idx2 = appwrite databases createIndex `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key idx_platform_product_id `
--type unique `
--attributes product_platform_product_id `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Index idx_platform_product_id erstellt" -ForegroundColor Green
} else {
if ($idx2 -like "*already exists*" -or $idx2 -like "*duplicate*") {
Write-Host " [INFO] Index idx_platform_product_id existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $idx2" -ForegroundColor Yellow
}
}
# 4.3 Composite Index auf product_account_id + $createdAt
Write-Host " 4.3 Composite Index auf product_account_id + `$createdAt..." -ForegroundColor Gray
$idx3 = appwrite databases createIndex `
--database-id $DATABASE_ID `
--collection-id $COLLECTION_ID `
--key idx_account_created `
--type key `
--attributes product_account_id `$createdAt `
2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Index idx_account_created erstellt" -ForegroundColor Green
} else {
if ($idx3 -like "*already exists*" -or $idx3 -like "*duplicate*") {
Write-Host " [INFO] Index idx_account_created existiert bereits" -ForegroundColor Yellow
} else {
Write-Host " [WARNUNG] $idx3" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "=== Setup abgeschlossen ===" -ForegroundColor Green
Write-Host ""
Write-Host "Nächste Schritte:" -ForegroundColor Cyan
Write-Host "1. Prüfe die Berechtigungen in der Appwrite-Konsole" -ForegroundColor White
Write-Host "2. Stelle sicher, dass authentifizierte Benutzer Read/Write-Rechte haben" -ForegroundColor White
Write-Host "3. Teste die Collection mit einem Produkt-Scan" -ForegroundColor White
Write-Host ""