Compare commits
5 Commits
d0066d3974
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5f68967c | ||
|
|
6a33ac6cff | ||
|
|
a29086173f | ||
|
|
a1201d572e | ||
| 75aef1941e |
@@ -3,8 +3,10 @@ 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 = 45000; // 45 seconds (listing with _ipg=240 can be slow)
|
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) */
|
/** Aktueller Scan-Fortschritt für GET_SCAN_PROGRESS (Polling durch Web-App) */
|
||||||
let currentScanProgress = null;
|
let currentScanProgress = null;
|
||||||
@@ -51,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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -68,6 +71,11 @@ chrome.runtime.onMessageExternal.addListener((msg, sender, 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") {
|
if (msg?.action === "GET_SCAN_PROGRESS") {
|
||||||
sendResponse(currentScanProgress ?? { percent: 0, phase: "idle", total: 0, current: 0, complete: false });
|
sendResponse(currentScanProgress ?? { percent: 0, phase: "idle", total: 0, current: 0, complete: false });
|
||||||
return false;
|
return false;
|
||||||
@@ -93,7 +101,10 @@ async function handleParseRequest(url, sendResponse) {
|
|||||||
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);
|
console.log("[BACKGROUND] Tab created:", tabId);
|
||||||
|
|
||||||
@@ -323,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;
|
||||||
|
|
||||||
@@ -403,7 +415,10 @@ async function handleScanProductsRequest(url, accountId, sendResponse) {
|
|||||||
url: targetUrl,
|
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
|
||||||
@@ -555,7 +570,10 @@ async function loadAndParseEachItemTab(items) {
|
|||||||
url: item.url,
|
url: item.url,
|
||||||
active: false
|
active: false
|
||||||
});
|
});
|
||||||
|
if (!tab?.id) {
|
||||||
|
console.warn(`[BACKGROUND] Tab could not be created for item ${i + 1}, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const listener = (tabId, changeInfo) => {
|
const listener = (tabId, changeInfo) => {
|
||||||
if (tabId === tab.id && changeInfo.status === "complete") {
|
if (tabId === tab.id && changeInfo.status === "complete") {
|
||||||
@@ -640,6 +658,7 @@ async function handleScanComplete(tabId, data) {
|
|||||||
* 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;
|
||||||
|
|
||||||
@@ -676,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] || "";
|
||||||
|
|||||||
@@ -72,6 +72,50 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
return true;
|
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()
|
||||||
@@ -1051,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"
|
||||||
|
|||||||
@@ -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
785
Server/backend/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Server/public/assets/platforms/README.md
Normal file
8
Server/public/assets/platforms/README.md
Normal 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.
|
||||||
@@ -3,16 +3,22 @@ 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";
|
||||||
@@ -22,6 +28,21 @@ import { fetchManagedAccounts } from "./services/accountsService";
|
|||||||
import { useScan } from "./context/ScanContext";
|
import { useScan } from "./context/ScanContext";
|
||||||
import ScanningLoader from "./components/ui/ScanningLoader";
|
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 { scanning, scanProgress } = useScan();
|
||||||
@@ -119,7 +140,10 @@ export default function App() {
|
|||||||
|
|
||||||
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);
|
setUserExtensionLoad(null);
|
||||||
@@ -171,7 +195,10 @@ export default function App() {
|
|||||||
|
|
||||||
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);
|
setUserExtensionLoad(null);
|
||||||
@@ -390,11 +417,22 @@ 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,
|
disabled: scanning,
|
||||||
onClick: (e) => {
|
onClick: (e) => {
|
||||||
@@ -403,11 +441,37 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
||||||
@@ -416,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
|
||||||
@@ -434,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 />;
|
||||||
};
|
};
|
||||||
@@ -471,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
129
Server/src/components/ScrollSnapDummyPage.jsx
Normal file
129
Server/src/components/ScrollSnapDummyPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -328,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
|
||||||
@@ -379,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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import ColourfulText from "@/components/ui/colourful-text";
|
|||||||
import { PlaceholdersAndVanishInput } from "@/components/ui/placeholders-and-vanish-input";
|
import { PlaceholdersAndVanishInput } from "@/components/ui/placeholders-and-vanish-input";
|
||||||
import { MultiStepLoader } from "@/components/ui/multi-step-loader";
|
import { MultiStepLoader } from "@/components/ui/multi-step-loader";
|
||||||
import { IPhoneNotification } from "@/components/ui/iphone-notification";
|
import { IPhoneNotification } from "@/components/ui/iphone-notification";
|
||||||
import { parseEbayAccount } from "@/services/ebayParserService";
|
import { parseEbayAccount, parseViaExtensionExtended } from "@/services/ebayParserService";
|
||||||
import { createManagedAccount, fetchManagedAccounts } from "@/services/accountsService";
|
import { createManagedAccount, fetchManagedAccounts, determineRefreshStatus } from "@/services/accountsService";
|
||||||
import { account, databases, databaseId, usersCollectionId } from "@/lib/appwrite";
|
import { account, databases, databaseId, usersCollectionId } from "@/lib/appwrite";
|
||||||
import { DottedGlowBackground } from "@/components/ui/dotted-glow-background";
|
import { DottedGlowBackground } from "@/components/ui/dotted-glow-background";
|
||||||
|
|
||||||
@@ -618,13 +618,20 @@ export const OnboardingGate = ({ userName, onStart, loading, error, initialPhase
|
|||||||
setPhase("loading");
|
setPhase("loading");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse eBay account
|
// Parse eBay account (erweitert mit Feedback, About, Store)
|
||||||
// #region agent log
|
// #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:'OnboardingGate.jsx:449',message:'handleAccountSubmit: before parseEbayAccount',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
|
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:449',message:'handleAccountSubmit: before parseViaExtensionExtended',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
|
||||||
// #endregion
|
// #endregion
|
||||||
const accountData = await parseEbayAccount(url);
|
let accountData;
|
||||||
|
try {
|
||||||
|
accountData = await parseViaExtensionExtended(url);
|
||||||
|
} catch (extendedError) {
|
||||||
|
// Fallback zu normalem Parsing wenn erweiterte Funktion fehlschlägt
|
||||||
|
console.warn("Extended parsing failed, falling back to normal parsing:", extendedError);
|
||||||
|
accountData = await parseEbayAccount(url);
|
||||||
|
}
|
||||||
// #region agent log
|
// #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:'OnboardingGate.jsx:452',message:'handleAccountSubmit: parseEbayAccount success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
|
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:452',message:'handleAccountSubmit: parseViaExtensionExtended success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{});
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// Validate that sellerId was extracted successfully
|
// Validate that sellerId was extracted successfully
|
||||||
@@ -676,6 +683,21 @@ export const OnboardingGate = ({ userName, onStart, loading, error, initialPhase
|
|||||||
account_url: url,
|
account_url: url,
|
||||||
account_status: accountData.status || "active",
|
account_status: accountData.status || "active",
|
||||||
account_sells: accountData.stats?.itemsSold ?? null, // Setze account_sells wenn verfügbar
|
account_sells: accountData.stats?.itemsSold ?? null, // Setze account_sells wenn verfügbar
|
||||||
|
// Neue erweiterte Felder
|
||||||
|
account_response_time_hours: accountData.responseTimeHours ?? null,
|
||||||
|
account_followers: accountData.followers ?? null,
|
||||||
|
account_feedback_total: accountData.feedbackTotal ?? null,
|
||||||
|
account_feedback_12m_positive: accountData.feedback12mPositive ?? null,
|
||||||
|
account_feedback_12m_neutral: accountData.feedback12mNeutral ?? null,
|
||||||
|
account_feedback_12m_negative: accountData.feedback12mNegative ?? null,
|
||||||
|
account_last_refresh_at: new Date().toISOString(),
|
||||||
|
account_last_refresh_status: accountData.partialResults ?
|
||||||
|
determineRefreshStatus(accountData.partialResults, {
|
||||||
|
followers: accountData.followers,
|
||||||
|
feedbackTotal: accountData.feedbackTotal,
|
||||||
|
feedback12mPositive: accountData.feedback12mPositive,
|
||||||
|
responseTimeHours: accountData.responseTimeHours
|
||||||
|
}) : null,
|
||||||
});
|
});
|
||||||
// #region agent log
|
// #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:'OnboardingGate.jsx:494',message:'handleAccountSubmit: createManagedAccount success',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H3'})}).catch(()=>{});
|
fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:494',message:'handleAccountSubmit: createManagedAccount success',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H3'})}).catch(()=>{});
|
||||||
|
|||||||
51
Server/src/components/ui/GradientText.css
Normal file
51
Server/src/components/ui/GradientText.css
Normal 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;
|
||||||
|
}
|
||||||
100
Server/src/components/ui/GradientText.jsx
Normal file
100
Server/src/components/ui/GradientText.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
Server/src/components/ui/bento-grid.jsx
Normal file
43
Server/src/components/ui/bento-grid.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -103,6 +565,18 @@ export const AccountsPage = () => {
|
|||||||
updatePayload.account_shop_name = parsedData.shopName || null;
|
updatePayload.account_shop_name = parsedData.shopName || null;
|
||||||
updatePayload.account_sells = parsedData.stats?.itemsSold ?? null;
|
updatePayload.account_sells = parsedData.stats?.itemsSold ?? null;
|
||||||
|
|
||||||
|
// Neue erweiterte Felder
|
||||||
|
updatePayload.account_response_time_hours = parsedData.responseTimeHours ?? null;
|
||||||
|
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
|
||||||
@@ -110,7 +584,76 @@ export const AccountsPage = () => {
|
|||||||
// Setze account_updated_at auf aktuelle Zeit
|
// Setze account_updated_at auf aktuelle Zeit
|
||||||
updatePayload.account_updated_at = new Date().toISOString();
|
updatePayload.account_updated_at = new Date().toISOString();
|
||||||
|
|
||||||
await updateManagedAccount(accountId, updatePayload);
|
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();
|
||||||
@@ -128,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);
|
||||||
}
|
}
|
||||||
@@ -236,106 +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",
|
|
||||||
"Sales",
|
|
||||||
"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 === "Sales") {
|
|
||||||
const sales = row.account_sells;
|
|
||||||
if (sales === null || sales === undefined) return "-";
|
|
||||||
// Format number with thousand separators
|
|
||||||
return new Intl.NumberFormat("de-DE").format(sales);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (col === "Last Scan") {
|
|
||||||
const lastScan = row.account_updated_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">
|
||||||
@@ -359,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)]">
|
|
||||||
Sales (Auto)
|
|
||||||
</span>
|
|
||||||
: Anzahl der verkauften Artikel wird automatisch aus dem eBay-Profil gelesen.
|
|
||||||
</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 && (
|
||||||
@@ -444,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 */}
|
||||||
|
|||||||
7
Server/src/pages/AnalysisPage.jsx
Normal file
7
Server/src/pages/AnalysisPage.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
|
||||||
|
|
||||||
|
export const AnalysisPage = () => {
|
||||||
|
return <ScrollSnapDummyPage pageTitle="Analysis" />;
|
||||||
|
};
|
||||||
7
Server/src/pages/BlacklistPage.jsx
Normal file
7
Server/src/pages/BlacklistPage.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
|
||||||
|
|
||||||
|
export const BlacklistPage = () => {
|
||||||
|
return <ScrollSnapDummyPage pageTitle="Blacklist" />;
|
||||||
|
};
|
||||||
7
Server/src/pages/ItemsPage.jsx
Normal file
7
Server/src/pages/ItemsPage.jsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollSnapDummyPage } from "../components/ScrollSnapDummyPage";
|
||||||
|
|
||||||
|
export const ItemsPage = () => {
|
||||||
|
return <ScrollSnapDummyPage pageTitle="Items" />;
|
||||||
|
};
|
||||||
219
Server/src/services/accountMetricsService.js
Normal file
219
Server/src/services/accountMetricsService.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -201,6 +202,15 @@ export async function createManagedAccount(authUserId, accountData) {
|
|||||||
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: accountData.account_sells ?? null, // Setze account_sells wenn verfügbar
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
// account_status ist optional - aufgrund Schema-Konflikt vorerst weglassen
|
// account_status ist optional - aufgrund Schema-Konflikt vorerst weglassen
|
||||||
@@ -323,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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -521,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
|
||||||
|
|||||||
BIN
bilder/Bildschirmfoto 2026-01-26 um 17.29.55.png
Normal file
BIN
bilder/Bildschirmfoto 2026-01-26 um 17.29.55.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
bilder/Bildschirmfoto 2026-01-26 um 17.30.20.png
Normal file
BIN
bilder/Bildschirmfoto 2026-01-26 um 17.30.20.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
bilder/Bildschirmfoto 2026-01-26 um 17.31.18.png
Normal file
BIN
bilder/Bildschirmfoto 2026-01-26 um 17.31.18.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
86
setup/create-account-metrics-collection.ps1
Normal file
86
setup/create-account-metrics-collection.ps1
Normal 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 ""
|
||||||
Reference in New Issue
Block a user