// 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 (
);
}
// ─── 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 ? (
) : 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 }) => (
))}
)}
{/* Recent activity log */}
Recent Activity
{recentLogs.length === 0 ? (
No activity yet — waiting for import to start…
) : (
recentLogs.map((entry, i) => )
)}
)}
)}
);
}