// app/routes/app.dashboard.jsx — Live import progress dashboard import { useEffect, useState, useRef, useCallback } from "react"; import { json } from "@remix-run/node"; import { useLoaderData, useNavigate } from "@remix-run/react"; import { Page, Layout, Card, Text, BlockStack, InlineStack, Badge, ProgressBar, Button, Spinner, Box, Divider } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react"; import { authenticate } from "../shopify.server"; const BACKEND = "https://backend.data4autos.com"; const POLL_INTERVAL = 3000; export const loader = async ({ request }) => { const { session } = await authenticate.admin(request); return json({ shop: session.shop }); }; // ─── helpers ──────────────────────────────────────────────────────────────── function elapsed(startedAt) { if (!startedAt) return "—"; const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000); if (secs < 60) return `${secs}s`; const m = Math.floor(secs / 60), s = secs % 60; if (m < 60) return `${m}m ${s}s`; const h = Math.floor(m / 60), mm = m % 60; return `${h}h ${mm}m`; } function fmt(d) { if (!d) return "—"; return new Date(d).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function statusTone(status) { if (!status) return "subdued"; if (status === "done") return "success"; if (status === "error") return "critical"; if (status === "cancelled") return "warning"; if (status === "importing" || status === "fetching_products") return "info"; return "subdued"; } function statusLabel(status) { const map = { started: "Starting", fetching_products: "Fetching Products", importing: "Importing", done: "Completed", error: "Error", cancelled: "Cancelled", cancelling: "Cancelling…", }; return map[status] || (status || "Unknown"); } function stepLabel(step) { const map = { started: "Initialising", fetching_products: "Fetching products from Turn14", importing: "Creating / updating products in Shopify", completed: "Import complete", error: "Import stopped — error", cancelled: "Import cancelled", }; return map[step] || step || "—"; } function parseLogTone(line) { if (!line) return "subdued"; if (line.includes("[PRODUCT-OK]") || line.includes("[IMPORT-DONE]") || line.includes("[FETCH-OK]")) return "success"; if (line.includes("[PRODUCT-FAIL]") || line.includes("[ERROR]") || line.includes("[FETCH-FAIL]")) return "critical"; if (line.includes("[SKIP]")) return "warning"; if (line.includes("[PRODUCT]") || line.includes("[STATS]")) return "info"; return "subdued"; } function parseLogTitle(line) { if (!line) return "Log"; if (line.includes("[PRODUCT-OK]")) return "Product created"; if (line.includes("[PRODUCT-FAIL]")) return "Product failed"; if (line.includes("[SKIP]")) return "Skipped duplicate"; if (line.includes("[IMPORT-DONE]")) return "Import complete"; if (line.includes("[IMPORT-START]")) return "Import started"; if (line.includes("[FETCH-OK]")) return "Turn14 fetch success"; if (line.includes("[FETCH-FAIL]")) return "Turn14 fetch failed"; if (line.includes("[FETCH]")) return "Fetching from Turn14"; if (line.includes("[STATS]")) return "Progress update"; if (line.includes("[PRODUCT]")) return "Processing product"; if (line.includes("[ERROR]")) return "Error"; if (line.includes("[CANCEL]")) return "Cancelled"; return "Activity"; } const toneColors = { success: { bg: "#f0fdf4", border: "#bbf7d0", dot: "#16a34a", text: "#166534" }, critical: { bg: "#fff1f2", border: "#fecdd3", dot: "#dc2626", text: "#991b1b" }, warning: { bg: "#fffbeb", border: "#fde68a", dot: "#d97706", text: "#92400e" }, info: { bg: "#eff6ff", border: "#bfdbfe", dot: "#2563eb", text: "#1e40af" }, subdued: { bg: "#f9fafb", border: "#e5e7eb", dot: "#9ca3af", text: "#6b7280" }, }; // ─── StatCard ──────────────────────────────────────────────────────────────── function StatCard({ label, value, sub, accent }) { const accents = { blue: { bg: "#eff6ff", border: "#bfdbfe", val: "#1d4ed8" }, green: { bg: "#f0fdf4", border: "#bbf7d0", val: "#15803d" }, red: { bg: "#fff1f2", border: "#fecdd3", val: "#dc2626" }, amber: { bg: "#fffbeb", border: "#fde68a", val: "#b45309" }, purple: { bg: "#faf5ff", border: "#e9d5ff", val: "#7c3aed" }, slate: { bg: "#f8fafc", border: "#e2e8f0", val: "#475569" }, }; const c = accents[accent] || accents.slate; return (
{label}
{value ?? "—"}
{sub &&
{sub}
}
); } // ─── StageRow ───────────────────────────────────────────────────────────────── function StageRow({ label, status, value, meta }) { const dotColor = status === "done" ? "#16a34a" : status === "active" ? "#2563eb" : status === "error" ? "#dc2626" : "#d1d5db"; const textColor = status === "active" ? "#1d4ed8" : status === "done" ? "#166534" : "#6b7280"; return (
{label}
{meta &&
{meta}
} {value && (
{value}
)}
); } // ─── LogEntry ──────────────────────────────────────────────────────────────── function LogEntry({ entry }) { const tone = parseLogTone(entry.line); const title = parseLogTitle(entry.line); const c = toneColors[tone]; const cleanLine = entry.line.replace(/^\[[\w-]+\]\s*/, ""); return (
{title}
{cleanLine}
{fmt(entry.at)}
); } // ─── Main component ────────────────────────────────────────────────────────── export default function Dashboard() { const { shop } = useLoaderData(); const navigate = useNavigate(); const [jobs, setJobs] = useState([]); const [selectedJobId, setSelectedJobId] = useState(null); const [job, setJob] = useState(null); const [loading, setLoading] = useState(true); const [tick, setTick] = useState(0); const [now, setNow] = useState(Date.now()); const pollRef = useRef(null); // Fetch job list const fetchJobs = useCallback(async () => { try { const resp = await fetch(`${BACKEND}/jobs?shop=${encodeURIComponent(shop)}`); const data = await resp.json(); const list = data.jobs || []; setJobs(list); if (!selectedJobId && list.length > 0) setSelectedJobId(list[0].id); } catch {} }, [shop, selectedJobId]); // Fetch selected job detail const fetchJob = useCallback(async (id) => { if (!id) return; try { const resp = await fetch(`${BACKEND}/jobs/${id}`); if (!resp.ok) return; const j = await resp.json(); setJob(j); } catch {} }, []); // Initial load useEffect(() => { (async () => { setLoading(true); await fetchJobs(); setLoading(false); })(); }, []); // Select job when list arrives useEffect(() => { if (!selectedJobId && jobs.length > 0) setSelectedJobId(jobs[0].id); }, [jobs]); // Poll selected job useEffect(() => { if (!selectedJobId) return; fetchJob(selectedJobId); pollRef.current = setInterval(async () => { await fetchJob(selectedJobId); setTick(t => t + 1); }, POLL_INTERVAL); return () => clearInterval(pollRef.current); }, [selectedJobId]); // Elapsed timer useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []); const handleCancel = async () => { if (!selectedJobId) return; await fetch(`${BACKEND}/jobs/${selectedJobId}/cancel`, { method: "POST" }); fetchJob(selectedJobId); }; const isRunning = job && (job.status === "importing" || job.status === "fetching_products" || job.status === "started"); const s = job?.liveStats || { total: 0, processed: 0, created: 0, skipped: 0, failed: 0, remaining: 0, successRate: 0 }; const progress = s.total > 0 ? Math.round((s.processed / s.total) * 100) : 0; const elapsedStr = job?.startedAt ? elapsed(job.startedAt) : "—"; // Stage states const stageStatus = (stageName) => { if (!job) return "pending"; if (job.status === "error" && job.step === stageName) return "error"; if (job.step === stageName || job.status === stageName) return "active"; const order = ["started", "fetching_products", "importing", "completed"]; const jobIdx = order.indexOf(job.step || job.status); const stageIdx = order.indexOf(stageName); if (jobIdx > stageIdx) return "done"; return "pending"; }; const recentLogs = [...(job?.logs || [])].reverse().slice(0, 10); return ( {/* ── Header ── */}
📊 Import Dashboard
{shop}
{isRunning && ( )}
{loading ? (
Loading jobs…
) : jobs.length === 0 ? (
📦
No import jobs yet
Start an import from the Manage Brands page to see live progress here.
) : ( {/* ── Job selector sidebar ── */} {jobs.length > 1 && (
RECENT IMPORTS
{jobs.slice(0, 10).map(j => (
setSelectedJobId(j.id)} style={{ padding: "12px 16px", cursor: "pointer", borderBottom: "1px solid #f9fafb", background: j.id === selectedJobId ? "#eff6ff" : "transparent", borderLeft: j.id === selectedJobId ? "3px solid #2563eb" : "3px solid transparent" }} >
{j.brandName || `Brand ${j.brandId}`}
● {statusLabel(j.status)} {fmt(j.startedAt)}
))}
)} {/* ── Main job detail ── */} {job && ( {/* Status + Brand header */} {job.brandName || `Brand ${job.brandId}`} Job ID: {job.id} {isRunning && } {statusLabel(job.status)} {/* Progress bar */} {stepLabel(job.step)} {progress}% {/* Timing row */}
Started
{fmt(job.startedAt)}
Elapsed
{isRunning ? elapsed(job.startedAt) : (job.durationSeconds ? `${job.durationSeconds}s` : elapsedStr)}
Finished
{fmt(job.finishedAt)}
{/* Current product banner */} {job.currentProduct && (
Currently importing
{job.currentProduct.name}
{job.currentProduct.partNumber && (
Part: {job.currentProduct.partNumber}
)}
Product {job.currentProduct.number} of {job.currentProduct.total}
{job.currentProduct.number}
/ {job.currentProduct.total}
)} {/* Stats grid */}
= 90 ? "green" : s.successRate >= 70 ? "amber" : "red"} />
{/* Stage progress board */}
Import Stages
0 ? `${s.total} found` : null} /> 0 ? s.label : null} meta={s.failed > 0 ? `${s.failed} failed` : null} />
{/* Detail text */} {job.detail && (
ℹ️ {job.detail}
)} {/* Error details */} {job.status === "error" && (
⛔ Import Error
{job.detail}
)} {/* Per-product errors */} {job.errors?.length > 0 && (
Failed Products ({job.errors.length})
{job.errors.map((e, i) => (
#{e.index} {e.product}
{e.error}
))}
)} {/* Completion summary */} {job.status === "done" && (
✅ Import Completed
{[ { l: "Total products", v: s.total }, { l: "Created", v: s.created }, { l: "Skipped", v: s.skipped }, { l: "Failed", v: s.failed }, { l: "Success rate", v: `${s.successRate}%` }, { l: "Duration", v: job.durationSeconds ? `${job.durationSeconds}s` : "—" }, ].map(({ l, v }) => (
{l}
{v}
))}
)} {/* Recent activity log */}
Recent Activity
{recentLogs.length === 0 ? (
No activity yet — waiting for import to start…
) : ( recentLogs.map((entry, i) => ) )}
)} )} ); }