enhance job summary and dashboard styles with new analytics and logging components
This commit is contained in:
parent
3bd27d395c
commit
a96d8ad3b0
@ -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 }) {
|
function JobSummary({ job }) {
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return (
|
return (
|
||||||
@ -136,6 +349,154 @@ function JobSummary({ job }) {
|
|||||||
Math.round(((job.stepIndex || 0) / (job.totalSteps || 6)) * 100),
|
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 (
|
return (
|
||||||
<div className={styles.jobPanel}>
|
<div className={styles.jobPanel}>
|
||||||
@ -158,7 +519,7 @@ function JobSummary({ job }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.progressMeta}>
|
<div className={styles.progressMeta}>
|
||||||
<span>{job.step || "-"}</span>
|
<span>{currentStepLabel}</span>
|
||||||
<span>{progressPercent}% complete</span>
|
<span>{progressPercent}% complete</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.progressTrack}>
|
<div className={styles.progressTrack}>
|
||||||
@ -168,25 +529,115 @@ function JobSummary({ job }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.progressOverviewGrid}>
|
||||||
|
<div className={styles.progressOverviewCard}>
|
||||||
|
<span className={styles.statLabel}>Current stage</span>
|
||||||
|
<strong className={styles.progressOverviewValue}>{currentStepLabel}</strong>
|
||||||
|
<span className={styles.progressOverviewMeta}>
|
||||||
|
Step {job.stepIndex || 0} of {job.totalSteps || 6}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressOverviewCard}>
|
||||||
|
<span className={styles.statLabel}>Started</span>
|
||||||
|
<strong className={styles.progressOverviewValue}>
|
||||||
|
{formatDateTime(job.startedAt)}
|
||||||
|
</strong>
|
||||||
|
<span className={styles.progressOverviewMeta}>Import start time</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressOverviewCard}>
|
||||||
|
<span className={styles.statLabel}>Last update</span>
|
||||||
|
<strong className={styles.progressOverviewValue}>
|
||||||
|
{formatDateTime(job.updatedAt)}
|
||||||
|
</strong>
|
||||||
|
<span className={styles.progressOverviewMeta}>Latest backend activity</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.progressOverviewCard}>
|
||||||
|
<span className={styles.statLabel}>Elapsed</span>
|
||||||
|
<strong className={styles.progressOverviewValue}>
|
||||||
|
{formatElapsed(job.startedAt, job.updatedAt)}
|
||||||
|
</strong>
|
||||||
|
<span className={styles.progressOverviewMeta}>Time spent on this run</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analyticsCards.length ? (
|
||||||
|
<div className={styles.analyticsGrid}>
|
||||||
|
{analyticsCards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.key}
|
||||||
|
className={`${styles.analyticsCard} ${styles[`analytics${card.tone[0].toUpperCase()}${card.tone.slice(1)}`]}`}
|
||||||
|
>
|
||||||
|
<span className={styles.analyticsLabel}>{card.label}</span>
|
||||||
|
<strong className={styles.analyticsValue}>{card.value}</strong>
|
||||||
|
<span className={styles.analyticsMeta}>{card.meta}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.highlightGrid}>
|
||||||
|
{highlightCards.map((card) => (
|
||||||
|
<div key={card.key} className={styles.highlightCard}>
|
||||||
|
<span className={styles.highlightLabel}>{card.label}</span>
|
||||||
|
<strong className={styles.highlightValue}>{card.value}</strong>
|
||||||
|
<span className={styles.highlightMeta}>{card.meta}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.timelineCard}>
|
||||||
|
<div className={styles.timelineHeader}>
|
||||||
|
<span className={styles.statLabel}>Stage progress</span>
|
||||||
|
<strong className={styles.timelineTitle}>Live pipeline board</strong>
|
||||||
|
<span className={styles.timelineSubtitle}>
|
||||||
|
Each row shows the current count for that stage while the import is running.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stageList}>
|
||||||
|
{stageItems.map((stage) => (
|
||||||
|
<div key={stage.key} className={styles.stageItem}>
|
||||||
|
<div className={styles.stageMain}>
|
||||||
|
<span
|
||||||
|
className={`${styles.stageDot} ${
|
||||||
|
stage.state === "done"
|
||||||
|
? styles.stageDone
|
||||||
|
: stage.state === "active"
|
||||||
|
? styles.stageActive
|
||||||
|
: stage.state === "error"
|
||||||
|
? styles.stageError
|
||||||
|
: styles.stagePending
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong className={styles.stageTitle}>{stage.title}</strong>
|
||||||
|
<p className={styles.stageMeta}>{stage.meta}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.stageValue}>{stage.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.statGrid}>
|
<div className={styles.statGrid}>
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<span className={styles.statLabel}>Step</span>
|
<span className={styles.statLabel}>Job status</span>
|
||||||
<strong>
|
<strong>
|
||||||
{job.stepIndex || 0}/{job.totalSteps || 6}
|
{job.status || "-"}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<span className={styles.statLabel}>Started</span>
|
<span className={styles.statLabel}>Completion</span>
|
||||||
<strong>{job.startedAt ? new Date(job.startedAt).toLocaleString() : "-"}</strong>
|
<strong>{progressPercent}%</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<span className={styles.statLabel}>Updated</span>
|
<span className={styles.statLabel}>Job key</span>
|
||||||
<strong>{job.updatedAt ? new Date(job.updatedAt).toLocaleString() : "-"}</strong>
|
<strong>{job.id}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.detailCard}>
|
<div className={styles.detailCard}>
|
||||||
<span className={styles.statLabel}>Detail</span>
|
<span className={styles.statLabel}>What is happening now</span>
|
||||||
<p>{job.detail || "-"}</p>
|
<p>{job.detail || "-"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -205,6 +656,34 @@ function JobSummary({ job }) {
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{recentLogs.length ? (
|
||||||
|
<div className={styles.logCard}>
|
||||||
|
<span className={styles.statLabel}>Recent activity</span>
|
||||||
|
<div className={styles.logList}>
|
||||||
|
{recentLogs.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.at}-${index}`}
|
||||||
|
className={`${styles.logLine} ${
|
||||||
|
entry.parsed?.tone === "success"
|
||||||
|
? styles.logSuccess
|
||||||
|
: entry.parsed?.tone === "error"
|
||||||
|
? styles.logError
|
||||||
|
: entry.parsed?.tone === "progress"
|
||||||
|
? styles.logProgress
|
||||||
|
: styles.logNeutral
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={styles.logTime}>
|
||||||
|
{entry.at ? new Date(entry.at).toLocaleTimeString() : ""}
|
||||||
|
</span>
|
||||||
|
<strong className={styles.logTitle}>{entry.parsed?.title || "Activity"}</strong>
|
||||||
|
<p className={styles.logDetail}>{entry.parsed?.detail || entry.line}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -343,7 +822,7 @@ export default function Index() {
|
|||||||
<s-section heading="Store Setup">
|
<s-section heading="Store Setup">
|
||||||
<div className={styles.panelBody}>
|
<div className={styles.panelBody}>
|
||||||
<div className={styles.inlineRow}>
|
<div className={styles.inlineRow}>
|
||||||
<span className={styles.statLabel}>Store status</span>
|
<span className={styles.statLabel}>Connection</span>
|
||||||
<span
|
<span
|
||||||
className={`${styles.statusPill} ${
|
className={`${styles.statusPill} ${
|
||||||
connection?.status === 1 ? styles.success : styles.warning
|
connection?.status === 1 ? styles.success : styles.warning
|
||||||
@ -352,14 +831,9 @@ export default function Index() {
|
|||||||
{connectionState}
|
{connectionState}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailCard}>
|
|
||||||
<span className={styles.statLabel}>Message</span>
|
|
||||||
<p>{connectionMessage}</p>
|
|
||||||
</div>
|
|
||||||
{connection?.status !== 1 ? (
|
{connection?.status !== 1 ? (
|
||||||
<div className={`${styles.detailCard} ${styles.warningCard}`}>
|
<div className={`${styles.detailCard} ${styles.warningCard}`}>
|
||||||
<span className={styles.statLabel}>What to do</span>
|
<p>{connectionMessage}</p>
|
||||||
<p>Connect your store first, then start the import.</p>
|
|
||||||
<div className={styles.actionRow}>
|
<div className={styles.actionRow}>
|
||||||
<s-button variant="primary" onClick={openSetup}>
|
<s-button variant="primary" onClick={openSetup}>
|
||||||
Connect store
|
Connect store
|
||||||
@ -367,27 +841,17 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`${styles.detailCard} ${styles.successCard}`}>
|
<p>{connectionMessage}</p>
|
||||||
<span className={styles.statLabel}>Store connection</span>
|
|
||||||
<p>Your store is ready. You can start importing products now.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</s-section>
|
</s-section>
|
||||||
|
|
||||||
<s-section
|
<s-section
|
||||||
heading="Start Product Import"
|
heading="Start Product Import"
|
||||||
description="Choose how much you want to import, then start the process."
|
description="Start the full Race Nation product import for this store."
|
||||||
>
|
>
|
||||||
<fetcher.Form method="post">
|
<fetcher.Form method="post">
|
||||||
<div className={styles.panelBody}>
|
<div className={styles.panelBody}>
|
||||||
<s-text-field
|
|
||||||
label="Product limit"
|
|
||||||
name="limit"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
details="Leave this empty to import all products, or enter a smaller number for a partial import."
|
|
||||||
/>
|
|
||||||
<div className={styles.actionRow}>
|
<div className={styles.actionRow}>
|
||||||
<s-button
|
<s-button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -406,15 +870,6 @@ export default function Index() {
|
|||||||
<s-section heading="Import Progress">
|
<s-section heading="Import Progress">
|
||||||
<JobSummary job={job} />
|
<JobSummary job={job} />
|
||||||
</s-section>
|
</s-section>
|
||||||
|
|
||||||
<s-section heading="What Happens Next">
|
|
||||||
<div className={styles.stepList}>
|
|
||||||
<div className={styles.stepItem}>1. Product information is collected.</div>
|
|
||||||
<div className={styles.stepItem}>2. Product images are prepared.</div>
|
|
||||||
<div className={styles.stepItem}>3. Images are uploaded to Shopify.</div>
|
|
||||||
<div className={styles.stepItem}>4. Products are created or updated in your store.</div>
|
|
||||||
</div>
|
|
||||||
</s-section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</s-page>
|
</s-page>
|
||||||
|
|||||||
@ -150,6 +150,192 @@
|
|||||||
gap: 1rem;
|
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 {
|
.panelTitle {
|
||||||
margin: 0.15rem 0 0;
|
margin: 0.15rem 0 0;
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
@ -205,6 +391,68 @@
|
|||||||
color: #e2e8f0;
|
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 {
|
.emptyPanel {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -247,6 +495,24 @@
|
|||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
|
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 {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
@ -277,7 +543,9 @@
|
|||||||
font-size: 1.55rem;
|
font-size: 1.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statGrid {
|
.analyticsGrid,
|
||||||
|
.statGrid,
|
||||||
|
.progressOverviewGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user