889 lines
28 KiB
JavaScript
889 lines
28 KiB
JavaScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useFetcher, useLoaderData } from "react-router";
|
|
import { useAppBridge } from "@shopify/app-bridge-react";
|
|
import { boundary } from "@shopify/shopify-app-react-router/server";
|
|
import { authenticate } from "../shopify.server";
|
|
import styles from "../styles/app-dashboard.module.css";
|
|
|
|
function getBackendApiUrl() {
|
|
return String(process.env.BACKEND_API_URL || "http://localhost:3002").replace(/\/+$/, "");
|
|
}
|
|
|
|
async function readJsonSafe(response) {
|
|
const text = await response.text();
|
|
try {
|
|
return text ? JSON.parse(text) : null;
|
|
} catch {
|
|
return { raw: text };
|
|
}
|
|
}
|
|
|
|
export const loader = async ({ request }) => {
|
|
const { session } = await authenticate.admin(request);
|
|
const backendApiUrl = getBackendApiUrl();
|
|
const shop = session.shop;
|
|
|
|
let connection = null;
|
|
let currentJob = null;
|
|
try {
|
|
const response = await fetch(
|
|
`${backendApiUrl}/shops/${encodeURIComponent(shop)}`,
|
|
);
|
|
connection = await readJsonSafe(response);
|
|
} catch (error) {
|
|
connection = {
|
|
status: 0,
|
|
message: `Backend unavailable: ${error.message}`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const statusResponse = await fetch(
|
|
`${backendApiUrl}/pipeline/status/${encodeURIComponent(shop)}`,
|
|
);
|
|
if (statusResponse.ok) {
|
|
currentJob = await readJsonSafe(statusResponse);
|
|
}
|
|
} catch {
|
|
currentJob = null;
|
|
}
|
|
|
|
return {
|
|
shop,
|
|
backendApiUrl,
|
|
connection,
|
|
currentJob,
|
|
};
|
|
};
|
|
|
|
export const action = async ({ request }) => {
|
|
const { session } = await authenticate.admin(request);
|
|
const formData = await request.formData();
|
|
const backendApiUrl = getBackendApiUrl();
|
|
const shop = session.shop;
|
|
const limitValue = String(formData.get("limit") || "").trim();
|
|
const limit = limitValue ? Number(limitValue) : null;
|
|
|
|
try {
|
|
const response = await fetch(`${backendApiUrl}/pipeline/run`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
shop,
|
|
limit: Number.isFinite(limit) && limit > 0 ? limit : null,
|
|
}),
|
|
});
|
|
|
|
const payload = await readJsonSafe(response);
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
error: payload?.error || "Failed to start import job.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
jobId: payload.jobId,
|
|
shop,
|
|
limit: payload.limit,
|
|
backendApiUrl,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: error.message,
|
|
};
|
|
}
|
|
};
|
|
|
|
function FieldState({ fields }) {
|
|
if (!fields) {
|
|
return <s-text tone="subdued">Your store is not ready yet.</s-text>;
|
|
}
|
|
|
|
return (
|
|
<s-stack direction="block" gap="tight">
|
|
{Object.entries(fields).map(([key, value]) => (
|
|
<s-inline-stack key={key} gap="base" alignItems="center">
|
|
<s-text>{key}</s-text>
|
|
<s-badge tone={value === "present" ? "success" : "critical"}>
|
|
{value}
|
|
</s-badge>
|
|
</s-inline-stack>
|
|
))}
|
|
</s-stack>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className={styles.emptyPanel}>
|
|
<div className={styles.emptyGlow}></div>
|
|
<h3>No import in progress</h3>
|
|
<p>Start an import to see the live progress and current step here.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const progressPercent = Math.max(
|
|
0,
|
|
Math.min(
|
|
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 (
|
|
<div className={styles.jobPanel}>
|
|
<div className={styles.jobHeader}>
|
|
<div>
|
|
<p className={styles.eyebrow}>Current import</p>
|
|
<h3 className={styles.panelTitle}>{job.id}</h3>
|
|
</div>
|
|
<span
|
|
className={`${styles.statusPill} ${
|
|
job.status === "done"
|
|
? styles.success
|
|
: job.status === "error"
|
|
? styles.error
|
|
: styles.active
|
|
}`}
|
|
>
|
|
{job.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div className={styles.progressMeta}>
|
|
<span>{currentStepLabel}</span>
|
|
<span>{progressPercent}% complete</span>
|
|
</div>
|
|
<div className={styles.progressTrack}>
|
|
<div
|
|
className={styles.progressFill}
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</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.statCard}>
|
|
<span className={styles.statLabel}>Job status</span>
|
|
<strong>
|
|
{job.status || "-"}
|
|
</strong>
|
|
</div>
|
|
<div className={styles.statCard}>
|
|
<span className={styles.statLabel}>Completion</span>
|
|
<strong>{progressPercent}%</strong>
|
|
</div>
|
|
<div className={styles.statCard}>
|
|
<span className={styles.statLabel}>Job key</span>
|
|
<strong>{job.id}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.detailCard}>
|
|
<span className={styles.statLabel}>What is happening now</span>
|
|
<p>{job.detail || "-"}</p>
|
|
</div>
|
|
|
|
{job.error ? (
|
|
<div className={`${styles.detailCard} ${styles.errorCard}`}>
|
|
<span className={styles.statLabel}>Error</span>
|
|
<p>{job.error}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{job.summary ? (
|
|
<div className={styles.jsonCard}>
|
|
<span className={styles.statLabel}>Summary</span>
|
|
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
|
<code>{JSON.stringify(job.summary, null, 2)}</code>
|
|
</pre>
|
|
</div>
|
|
) : 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>
|
|
);
|
|
}
|
|
|
|
export default function Index() {
|
|
const { shop, backendApiUrl, connection, currentJob } = useLoaderData();
|
|
const fetcher = useFetcher();
|
|
const shopify = useAppBridge();
|
|
const [job, setJob] = useState(currentJob);
|
|
const setupUrl = `${backendApiUrl}/auth/login?shop=${encodeURIComponent(shop)}`;
|
|
|
|
const isSubmitting =
|
|
["loading", "submitting"].includes(fetcher.state) &&
|
|
fetcher.formMethod === "POST";
|
|
|
|
const connectionState = useMemo(() => {
|
|
if (connection?.status === 1) {
|
|
return "Ready";
|
|
}
|
|
return "Setup needed";
|
|
}, [connection]);
|
|
|
|
const connectionMessage = useMemo(() => {
|
|
if (connection?.status === 1) {
|
|
return "Your store is connected and ready for import.";
|
|
}
|
|
|
|
if (String(connection?.message || "").toLowerCase().includes("fetch failed")) {
|
|
return "We could not reach the import service right now.";
|
|
}
|
|
|
|
if (String(connection?.message || "").toLowerCase().includes("shop not found")) {
|
|
return "This store still needs to be connected before imports can run.";
|
|
}
|
|
|
|
return "This store is not ready yet.";
|
|
}, [connection]);
|
|
|
|
const openSetup = () => {
|
|
if (typeof window !== "undefined") {
|
|
window.top.location.href = setupUrl;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!fetcher.data) {
|
|
return;
|
|
}
|
|
|
|
if (fetcher.data.ok && fetcher.data.jobId) {
|
|
setJob({
|
|
id: fetcher.data.jobId,
|
|
status: "queued",
|
|
step: "queued",
|
|
stepIndex: 0,
|
|
totalSteps: 6,
|
|
detail: "Job queued",
|
|
});
|
|
shopify.toast.show("KYT import job started");
|
|
return;
|
|
}
|
|
|
|
if (fetcher.data.error) {
|
|
shopify.toast.show(fetcher.data.error, { isError: true });
|
|
}
|
|
}, [fetcher.data, shopify]);
|
|
|
|
useEffect(() => {
|
|
if (!job?.id) {
|
|
return undefined;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
async function pollStatus() {
|
|
try {
|
|
const response = await fetch(
|
|
`${backendApiUrl}/pipeline/status/${encodeURIComponent(job.id)}`,
|
|
);
|
|
const payload = await readJsonSafe(response);
|
|
if (!cancelled && response.ok) {
|
|
setJob(payload);
|
|
}
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
setJob((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
detail: `Status polling failed: ${error.message}`,
|
|
}
|
|
: current,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pollStatus();
|
|
const timer = setInterval(pollStatus, 3000);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(timer);
|
|
};
|
|
}, [backendApiUrl, job?.id]);
|
|
|
|
return (
|
|
<s-page heading="Race Nation Imports">
|
|
<div className={styles.hero}>
|
|
<div className={styles.heroCopy}>
|
|
<p className={styles.kicker}>Import Center</p>
|
|
<h2>Bring KYT products into your store from one simple dashboard.</h2>
|
|
<p className={styles.heroText}>
|
|
Start an import, follow the progress, and keep track of what is
|
|
happening without leaving Shopify.
|
|
</p>
|
|
</div>
|
|
<div className={styles.heroMetrics}>
|
|
<div className={styles.metricTile}>
|
|
<span className={styles.metricLabel}>Store</span>
|
|
<strong>{shop}</strong>
|
|
</div>
|
|
<div className={styles.metricTile}>
|
|
<span className={styles.metricLabel}>Status</span>
|
|
<strong>{connectionState}</strong>
|
|
</div>
|
|
<div className={styles.metricTile}>
|
|
<span className={styles.metricLabel}>Import</span>
|
|
<strong>{job ? "In progress" : "Not started"}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.dashboardShell}>
|
|
<div className={styles.controlRow}>
|
|
<div className={styles.controlCard}>
|
|
<div className={styles.controlHeader}>
|
|
<div>
|
|
<span className={styles.statLabel}>Store setup</span>
|
|
<h3 className={styles.controlTitle}>Connection status</h3>
|
|
</div>
|
|
<span
|
|
className={`${styles.statusPill} ${
|
|
connection?.status === 1 ? styles.success : styles.warning
|
|
}`}
|
|
>
|
|
{connectionState}
|
|
</span>
|
|
</div>
|
|
<p className={styles.controlText}>{connectionMessage}</p>
|
|
{connection?.status !== 1 ? (
|
|
<div className={styles.actionRow}>
|
|
<s-button variant="primary" onClick={openSetup}>
|
|
Connect store
|
|
</s-button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className={styles.controlCard}>
|
|
<div className={styles.controlHeader}>
|
|
<div>
|
|
<span className={styles.statLabel}>Import action</span>
|
|
<h3 className={styles.controlTitle}>Start product import</h3>
|
|
</div>
|
|
</div>
|
|
<p className={styles.controlText}>
|
|
Launch the full Race Nation import for this store and track the live sync below.
|
|
</p>
|
|
<fetcher.Form method="post">
|
|
<div className={styles.controlForm}>
|
|
<div className={styles.actionRow}>
|
|
<s-button
|
|
type="submit"
|
|
variant="primary"
|
|
{...(isSubmitting ? { loading: true } : {})}
|
|
>
|
|
Start import
|
|
</s-button>
|
|
</div>
|
|
</div>
|
|
</fetcher.Form>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.mainPanel}>
|
|
<div className={styles.mainPanelHeader}>
|
|
<div>
|
|
<span className={styles.statLabel}>Live dashboard</span>
|
|
<h3 className={styles.controlTitle}>Import progress</h3>
|
|
</div>
|
|
</div>
|
|
<JobSummary job={job} />
|
|
</div>
|
|
</div>
|
|
</s-page>
|
|
);
|
|
}
|
|
|
|
export const headers = (headersArgs) => {
|
|
return boundary.headers(headersArgs);
|
|
};
|