diff --git a/app/routes/app._index.jsx b/app/routes/app._index.jsx index ee52a6c..ef52e68 100644 --- a/app/routes/app._index.jsx +++ b/app/routes/app._index.jsx @@ -118,6 +118,219 @@ function FieldState({ fields }) { ); } +function formatCounter(done, total) { + if (Number.isFinite(done) && Number.isFinite(total) && total > 0) { + return `${done} / ${total}`; + } + + if (Number.isFinite(done)) { + return String(done); + } + + return "-"; +} + +function getStageState(job, bucket) { + const activeStepMap = { + download: "downloadImages", + watermark: "watermarkImages", + upload: "uploadImagesToShopifyFiles", + convert: "convertToShopifyReady", + shopify: "upsertToShopify", + details: "fetchWebsiteData", + }; + + if (job?.status === "error") { + return "error"; + } + + if (job?.status === "done") { + return "done"; + } + + if (job?.step === activeStepMap[bucket]) { + return "active"; + } + + const stats = job?.liveStats || {}; + if (stats[bucket]?.total || stats[bucket]?.done || stats.stageSummaries?.[activeStepMap[bucket]]) { + return "done"; + } + + return "pending"; +} + +const STEP_LABELS = { + queued: "Queued", + starting: "Preparing import", + fetchWebsiteData: "Collecting KYT catalog data", + downloadImages: "Downloading product images", + watermarkImages: "Applying watermarks", + uploadImagesToShopifyFiles: "Uploading images to Shopify", + convertToShopifyReady: "Preparing Shopify product data", + upsertToShopify: "Creating or updating products", + completed: "Import completed", +}; + +function getStepLabel(step) { + return STEP_LABELS[step] || step || "-"; +} + +function formatDateTime(value) { + if (!value) { + return "-"; + } + + return new Date(value).toLocaleString(); +} + +function formatElapsed(startedAt, updatedAt) { + if (!startedAt) { + return "-"; + } + + const startMs = new Date(startedAt).getTime(); + const endMs = updatedAt ? new Date(updatedAt).getTime() : Date.now(); + + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) { + return "-"; + } + + const seconds = Math.floor((endMs - startMs) / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${remainingSeconds}s`; + } + + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } + + return `${remainingSeconds}s`; +} + +function parseRecentActivity(entry) { + const rawLine = String(entry?.line || ""); + const line = rawLine.replace(/^\[(log|info|warn|error)\]\s*/i, "").trim(); + + const watermarkMatch = line.match(/^\[WATERMARK\]\s+(\d+)\/(\d+)\s+processed/i); + if (watermarkMatch) { + return { + title: "Watermark progress updated", + detail: `${watermarkMatch[1]} of ${watermarkMatch[2]} images have been watermarked.`, + tone: "progress", + }; + } + + const imageDownloadMatch = line.match(/^\[IMAGES\]\s+(\d+)\/(\d+)\s+processed/i); + if (imageDownloadMatch) { + return { + title: "Image download progress updated", + detail: `${imageDownloadMatch[1]} of ${imageDownloadMatch[2]} product images have been downloaded.`, + tone: "progress", + }; + } + + const uploadProgressMatch = line.match(/^\[IMG-UPLOAD\]\s+(\d+)\/(\d+)\s+processed\s+\|\s+uploaded=(\d+)\s+skipped=(\d+)\s+failed=(\d+)/i); + if (uploadProgressMatch) { + return { + title: "Shopify image upload progress updated", + detail: `${uploadProgressMatch[3]} of ${uploadProgressMatch[2]} images are ready in Shopify files. ${uploadProgressMatch[4]} skipped and ${uploadProgressMatch[5]} failed so far.`, + tone: "progress", + }; + } + + const uploadOkMatch = line.match(/^\[IMG-UPLOAD-OK\]\s+(.+?)\s+\|\s+(.+?)\s+\|\s+fileId=(.+?)\s+\|\s+status=([A-Z]+)/i); + if (uploadOkMatch) { + return { + title: "Image uploaded to Shopify", + detail: `${uploadOkMatch[2]} is now in Shopify files with status ${uploadOkMatch[4]}.`, + tone: "success", + }; + } + + const uploadSkipMatch = line.match(/^\[IMG-UPLOAD-SKIP\]\s+(.+?)\s+\|\s+(.+?)\s+\|\s+(.+)/i); + if (uploadSkipMatch) { + return { + title: "Image reuse detected", + detail: `${uploadSkipMatch[2]} was already available, so upload was skipped.`, + tone: "neutral", + }; + } + + const uploadFailMatch = line.match(/^\[IMG-UPLOAD-FAIL\]\s+(.+?)\s+\|\s+(.+?)\s+->\s+(.+)/i); + if (uploadFailMatch) { + return { + title: "Image upload needs attention", + detail: `${uploadFailMatch[1]} could not be uploaded. ${uploadFailMatch[3]}`, + tone: "error", + }; + } + + const shopifyProgressMatch = line.match(/^\[SHOPIFY\]\s+(\d+)\/(\d+)\s+processed\s+\|\s+created=(\d+)\s+updated=(\d+)\s+failed=(\d+)/i); + if (shopifyProgressMatch) { + return { + title: "Product sync progress updated", + detail: `${shopifyProgressMatch[1]} of ${shopifyProgressMatch[2]} products processed. ${shopifyProgressMatch[3]} created, ${shopifyProgressMatch[4]} updated, ${shopifyProgressMatch[5]} failed.`, + tone: "progress", + }; + } + + const shopifyFailMatch = line.match(/^\[SHOPIFY-FAIL\]\s+(.+?)\s+->\s+(.+)/i); + if (shopifyFailMatch) { + return { + title: "Product sync needs attention", + detail: `${shopifyFailMatch[1]} could not be synced. ${shopifyFailMatch[2]}`, + tone: "error", + }; + } + + const detailProgressMatch = line.match(/^\[DETAIL\]\s+(\d+)\/(\d+)\s+completed/i); + if (detailProgressMatch) { + return { + title: "Product details collected", + detail: `${detailProgressMatch[1]} of ${detailProgressMatch[2]} products now have detailed catalog data.`, + tone: "progress", + }; + } + + const imageFailMatch = line.match(/^\[IMAGE-FAIL\]\s+(.+?)\s+\|\s+(.+?)\s+->\s+(.+)/i); + if (imageFailMatch) { + return { + title: "Image download needs attention", + detail: `${imageFailMatch[1]} has a download issue. ${imageFailMatch[3]}`, + tone: "error", + }; + } + + const watermarkFailMatch = line.match(/^\[WATERMARK-FAIL\]\s+(.+?)\s+->\s+(.+)/i); + if (watermarkFailMatch) { + return { + title: "Watermark step needs attention", + detail: `${watermarkFailMatch[1]} could not be watermarked. ${watermarkFailMatch[2]}`, + tone: "error", + }; + } + + const pipelineStepMatch = line.match(/^\[PIPELINE\s+(\d+)\/(\d+)\]\s+(.+)/i); + if (pipelineStepMatch) { + return { + title: `Pipeline step ${pipelineStepMatch[1]} of ${pipelineStepMatch[2]}`, + detail: pipelineStepMatch[3], + tone: "neutral", + }; + } + + return { + title: "Import activity updated", + detail: line, + tone: "neutral", + }; +} + function JobSummary({ job }) { if (!job) { return ( @@ -136,6 +349,154 @@ function JobSummary({ job }) { Math.round(((job.stepIndex || 0) / (job.totalSteps || 6)) * 100), ), ); + const liveStats = job.liveStats || {}; + const recentLogs = Array.isArray(job.logs) + ? job.logs.slice(-8).reverse().map((entry) => ({ + ...entry, + parsed: parseRecentActivity(entry), + })) + : []; + const summarySteps = job.summary?.steps || {}; + const downloadStats = liveStats.download || {}; + const watermarkStats = liveStats.watermark || {}; + const uploadStats = liveStats.upload || {}; + const shopifyStats = liveStats.shopify || {}; + const detailStats = liveStats.details || {}; + const activeStepLabel = job.detail || "Import running"; + const currentStepLabel = getStepLabel(job.step); + const analyticsCards = [ + { + key: "download", + label: "Downloaded", + value: formatCounter( + downloadStats.done ?? summarySteps.downloadImages?.downloaded, + downloadStats.total ?? summarySteps.downloadImages?.totalImagesFound, + ), + meta: `${downloadStats.skipped ?? summarySteps.downloadImages?.skipped ?? 0} skipped • ${downloadStats.failed ?? summarySteps.downloadImages?.failed ?? 0} failed`, + tone: "sunrise", + }, + { + key: "watermark", + label: "Watermarked", + value: formatCounter( + watermarkStats.done ?? summarySteps.watermarkImages?.processed, + watermarkStats.total ?? summarySteps.watermarkImages?.totalImagesFound, + ), + meta: `${watermarkStats.skipped ?? summarySteps.watermarkImages?.skipped ?? summarySteps.watermarkImages?.skippedCount ?? 0} skipped • ${watermarkStats.failed ?? summarySteps.watermarkImages?.failed ?? 0} failed`, + tone: "ice", + }, + { + key: "upload", + label: "Uploaded", + value: formatCounter( + uploadStats.uploaded ?? summarySteps.uploadImagesToShopifyFiles?.uploaded, + uploadStats.total ?? summarySteps.uploadImagesToShopifyFiles?.totalTasks, + ), + meta: `${uploadStats.skipped ?? summarySteps.uploadImagesToShopifyFiles?.skipped ?? 0} skipped • ${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} failed`, + tone: "mint", + }, + { + key: "shopify", + label: "Products synced", + value: formatCounter( + shopifyStats.done ?? summarySteps.upsertToShopify?.processed, + shopifyStats.total ?? summarySteps.upsertToShopify?.total, + ), + meta: `${shopifyStats.created ?? summarySteps.upsertToShopify?.created ?? 0} created • ${shopifyStats.updated ?? summarySteps.upsertToShopify?.updated ?? 0} updated`, + tone: "navy", + }, + ].filter((card) => card.value !== "-"); + const highlightCards = [ + { + key: "activity", + label: "Current activity", + value: activeStepLabel, + meta: `Step ${job.stepIndex || 0} of ${job.totalSteps || 6}`, + }, + { + key: "details", + label: "Product details", + value: formatCounter( + detailStats.done ?? summarySteps.fetchWebsiteData?.analysis?.detailFetchedNow, + detailStats.total ?? summarySteps.fetchWebsiteData?.analysis?.totalProductsUnique, + ), + meta: `${summarySteps.fetchWebsiteData?.analysis?.cachedDetailReused ?? 0} cached reused`, + }, + { + key: "ready", + label: "Images ready in Shopify", + value: String( + liveStats.uploadedOk ?? + summarySteps.uploadImagesToShopifyFiles?.uploaded ?? + 0, + ), + meta: `${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} upload issues`, + }, + { + key: "success", + label: "Sync success rate", + value: shopifyStats.successRate + ? `${shopifyStats.successRate}%` + : summarySteps.upsertToShopify?.successRate + ? `${summarySteps.upsertToShopify.successRate}%` + : job.status === "done" + ? "100%" + : "Running", + meta: job.status === "error" ? "Needs attention" : "Based on product sync stage", + }, + ]; + const stageItems = [ + { + key: "details", + title: "Fetch product details", + state: getStageState(job, "details"), + value: formatCounter( + detailStats.done ?? summarySteps.fetchWebsiteData?.analysis?.detailFetchedNow, + detailStats.total ?? summarySteps.fetchWebsiteData?.analysis?.totalProductsUnique, + ), + meta: `${summarySteps.fetchWebsiteData?.analysis?.cachedDetailReused ?? 0} reused from cache`, + }, + { + key: "download", + title: "Download product images", + state: getStageState(job, "download"), + value: formatCounter( + downloadStats.done ?? summarySteps.downloadImages?.downloaded, + downloadStats.total ?? summarySteps.downloadImages?.totalImagesFound, + ), + meta: `${downloadStats.failed ?? summarySteps.downloadImages?.failed ?? 0} failed`, + }, + { + key: "watermark", + title: "Apply watermark", + state: getStageState(job, "watermark"), + value: formatCounter( + watermarkStats.done ?? summarySteps.watermarkImages?.processed, + watermarkStats.total ?? summarySteps.watermarkImages?.totalImagesFound, + ), + meta: `${watermarkStats.failed ?? summarySteps.watermarkImages?.failed ?? 0} failed`, + }, + { + key: "upload", + title: "Upload images to Shopify", + state: getStageState(job, "upload"), + value: formatCounter( + uploadStats.uploaded ?? summarySteps.uploadImagesToShopifyFiles?.uploaded, + uploadStats.total ?? summarySteps.uploadImagesToShopifyFiles?.totalTasks, + ), + meta: `${uploadStats.skipped ?? summarySteps.uploadImagesToShopifyFiles?.skipped ?? 0} skipped • ${uploadStats.failed ?? summarySteps.uploadImagesToShopifyFiles?.failed ?? 0} failed`, + }, + { + key: "shopify", + title: "Create or update products", + state: getStageState(job, "shopify"), + value: formatCounter( + shopifyStats.done ?? summarySteps.upsertToShopify?.processed, + shopifyStats.total ?? summarySteps.upsertToShopify?.total, + ), + meta: `${shopifyStats.created ?? summarySteps.upsertToShopify?.created ?? 0} created • ${shopifyStats.updated ?? summarySteps.upsertToShopify?.updated ?? 0} updated`, + }, + ]; return (
@@ -158,7 +519,7 @@ function JobSummary({ job }) {
- {job.step || "-"} + {currentStepLabel} {progressPercent}% complete
@@ -168,25 +529,115 @@ function JobSummary({ job }) { />
+
+
+ Current stage + {currentStepLabel} + + Step {job.stepIndex || 0} of {job.totalSteps || 6} + +
+
+ Started + + {formatDateTime(job.startedAt)} + + Import start time +
+
+ Last update + + {formatDateTime(job.updatedAt)} + + Latest backend activity +
+
+ Elapsed + + {formatElapsed(job.startedAt, job.updatedAt)} + + Time spent on this run +
+
+ + {analyticsCards.length ? ( +
+ {analyticsCards.map((card) => ( +
+ {card.label} + {card.value} + {card.meta} +
+ ))} +
+ ) : null} + +
+ {highlightCards.map((card) => ( +
+ {card.label} + {card.value} + {card.meta} +
+ ))} +
+ +
+
+ Stage progress + Live pipeline board + + Each row shows the current count for that stage while the import is running. + +
+
+ {stageItems.map((stage) => ( +
+
+ +
+ {stage.title} +

{stage.meta}

+
+
+ {stage.value} +
+ ))} +
+
+
- Step + Job status - {job.stepIndex || 0}/{job.totalSteps || 6} + {job.status || "-"}
- Started - {job.startedAt ? new Date(job.startedAt).toLocaleString() : "-"} + Completion + {progressPercent}%
- Updated - {job.updatedAt ? new Date(job.updatedAt).toLocaleString() : "-"} + Job key + {job.id}
- Detail + What is happening now

{job.detail || "-"}

@@ -205,6 +656,34 @@ function JobSummary({ job }) { ) : null} + + {recentLogs.length ? ( +
+ Recent activity +
+ {recentLogs.map((entry, index) => ( +
+ + {entry.at ? new Date(entry.at).toLocaleTimeString() : ""} + + {entry.parsed?.title || "Activity"} +

{entry.parsed?.detail || entry.line}

+
+ ))} +
+
+ ) : null} ); } @@ -343,7 +822,7 @@ export default function Index() {
- Store status + Connection
-
- Message -

{connectionMessage}

-
{connection?.status !== 1 ? (
- What to do -

Connect your store first, then start the import.

+

{connectionMessage}

Connect store @@ -367,27 +841,17 @@ export default function Index() {
) : ( -
- Store connection -

Your store is ready. You can start importing products now.

-
+

{connectionMessage}

)}
-
- - -
-
1. Product information is collected.
-
2. Product images are prepared.
-
3. Images are uploaded to Shopify.
-
4. Products are created or updated in your store.
-
-
diff --git a/app/styles/app-dashboard.module.css b/app/styles/app-dashboard.module.css index e03679c..0d79c16 100644 --- a/app/styles/app-dashboard.module.css +++ b/app/styles/app-dashboard.module.css @@ -150,6 +150,192 @@ gap: 1rem; } +.analyticsGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; +} + +.analyticsCard { + padding: 1rem; + border-radius: 20px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: linear-gradient(135deg, rgba(255, 247, 237, 0.92), rgba(255, 255, 255, 0.98)); + display: grid; + gap: 0.35rem; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08); +} + +.analyticsSunrise { + background: linear-gradient(135deg, rgba(255, 237, 213, 0.96), rgba(255, 251, 235, 0.98)); +} + +.analyticsIce { + background: linear-gradient(135deg, rgba(224, 242, 254, 0.96), rgba(248, 250, 252, 0.98)); +} + +.analyticsMint { + background: linear-gradient(135deg, rgba(220, 252, 231, 0.96), rgba(240, 253, 244, 0.98)); +} + +.analyticsNavy { + background: linear-gradient(135deg, rgba(224, 231, 255, 0.96), rgba(238, 242, 255, 0.98)); +} + +.analyticsLabel { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #64748b; +} + +.analyticsValue { + font-size: 1.45rem; + line-height: 1.1; + color: #0f172a; +} + +.analyticsMeta { + font-size: 0.82rem; + color: #475569; +} + +.highlightGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.highlightCard, +.timelineCard { + padding: 1rem; + border-radius: 20px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94)); +} + +.highlightCard { + display: grid; + gap: 0.35rem; +} + +.highlightLabel, +.stageMeta { + font-size: 0.82rem; + color: #64748b; +} + +.highlightValue { + font-size: 1.1rem; + line-height: 1.25; + color: #0f172a; +} + +.highlightMeta { + font-size: 0.8rem; + color: #475569; +} + +.timelineHeader { + display: grid; + gap: 0.2rem; +} + +.timelineSubtitle, +.progressOverviewMeta { + font-size: 0.8rem; + color: #64748b; +} + +.timelineTitle { + color: #0f172a; + font-size: 1rem; +} + +.progressOverviewGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.progressOverviewCard { + padding: 1rem; + border-radius: 20px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94)); + display: grid; + gap: 0.3rem; +} + +.progressOverviewValue { + font-size: 1rem; + line-height: 1.35; + color: #0f172a; +} + +.stageList { + display: grid; + gap: 0.75rem; + margin-top: 0.85rem; +} + +.stageItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.85rem; + padding: 0.85rem 0.95rem; + border-radius: 16px; + background: rgba(248, 250, 252, 0.92); + border: 1px solid rgba(226, 232, 240, 0.95); +} + +.stageMain { + display: flex; + align-items: flex-start; + gap: 0.75rem; + flex: 1; +} + +.stageDot { + width: 0.85rem; + height: 0.85rem; + margin-top: 0.15rem; + border-radius: 999px; + flex: 0 0 auto; +} + +.stagePending { + background: #cbd5e1; +} + +.stageActive { + background: #f97316; + box-shadow: 0 0 0 6px rgba(249, 115, 22, 0.15); +} + +.stageDone { + background: #16a34a; + box-shadow: 0 0 0 6px rgba(22, 163, 74, 0.12); +} + +.stageError { + background: #dc2626; + box-shadow: 0 0 0 6px rgba(220, 38, 38, 0.12); +} + +.stageTitle { + display: block; + color: #0f172a; + margin-bottom: 0.18rem; +} + +.stageValue { + font-weight: 700; + color: #0f172a; + white-space: nowrap; +} + .panelTitle { margin: 0.15rem 0 0; font-size: 1.15rem; @@ -205,6 +391,68 @@ color: #e2e8f0; } +.logCard { + padding: 1rem; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 20px; + background: #111827; + color: #e5e7eb; +} + +.logList { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + max-height: 18rem; + overflow: auto; +} + +.logLine { + display: grid; + gap: 0.25rem; + padding: 0.7rem 0.8rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid transparent; +} + +.logNeutral { + border-color: rgba(148, 163, 184, 0.16); +} + +.logProgress { + border-color: rgba(251, 191, 36, 0.24); + background: rgba(251, 191, 36, 0.08); +} + +.logSuccess { + border-color: rgba(74, 222, 128, 0.24); + background: rgba(34, 197, 94, 0.08); +} + +.logError { + border-color: rgba(248, 113, 113, 0.24); + background: rgba(239, 68, 68, 0.08); +} + +.logTitle { + font-size: 0.9rem; + color: #f8fafc; +} + +.logDetail { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.82rem; + color: #dbe4f0; +} + +.logTime { + font-size: 0.75rem; + color: #94a3b8; +} + .emptyPanel { position: relative; overflow: hidden; @@ -247,6 +495,24 @@ background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94)); } +@media (max-width: 900px) { + .analyticsGrid, + .statGrid, + .highlightGrid, + .progressOverviewGrid { + grid-template-columns: 1fr; + } + + .stageItem { + flex-direction: column; + align-items: flex-start; + } + + .stageValue { + margin-left: 1.6rem; + } +} + @keyframes pulse { 0%, 100% { @@ -277,7 +543,9 @@ font-size: 1.55rem; } - .statGrid { + .analyticsGrid, + .statGrid, + .progressOverviewGrid { grid-template-columns: 1fr; } }