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 Your store is not ready yet.; } return ( {Object.entries(fields).map(([key, value]) => ( {key} {value} ))} ); } 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 (

No import in progress

Start an import to see the live progress and current step here.

); } 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 (

Current import

{job.id}

{job.status}
{currentStepLabel} {progressPercent}% complete
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}
))}
Job status {job.status || "-"}
Completion {progressPercent}%
Job key {job.id}
What is happening now

{job.detail || "-"}

{job.error ? (
Error

{job.error}

) : null} {job.summary ? (
Summary
            {JSON.stringify(job.summary, null, 2)}
          
) : 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}
); } 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 (

Import Center

Bring KYT products into your store from one simple dashboard.

Start an import, follow the progress, and keep track of what is happening without leaving Shopify.

Store {shop}
Status {connectionState}
Import {job ? "In progress" : "Not started"}
Connection {connectionState}
{connection?.status !== 1 ? (

{connectionMessage}

Connect store
) : (

{connectionMessage}

)}
Start import
); } export const headers = (headersArgs) => { return boundary.headers(headersArgs); };