- app.dashboard.jsx (new): live import progress dashboard modelled on Race-Nation — job selector, progress bar, 6-stat grid, stage board, current product banner, activity log, errors list, completion summary, 3s polling, cancel button - app.jsx: add nav links for Dashboard, Settings, Brands, Manage Brands, Help - app._index.jsx: dark gradient hero header, subscription status bar, navcard grid, billing modal preserved - app.settings.jsx: dark header, Turn14 connect card with live status, visual pricing type toggle (MAP vs percentage) - app.brands.jsx: dark header, visual brand grid with checkbox state, sticky save toolbar - app.managebrand.jsx: dark header, live import status bar with Dashboard link, collapsible brand rows, filter toggle pills, modern product cards with attribute badges - app.help.jsx: dark header, animated FAQ accordion, styled contact card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
489 lines
24 KiB
JavaScript
489 lines
24 KiB
JavaScript
// 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 (
|
||
<div style={{ background: c.bg, border: `1px solid ${c.border}`, borderRadius: 12, padding: "18px 20px", minWidth: 0 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>{label}</div>
|
||
<div style={{ fontSize: 28, fontWeight: 800, color: c.val, lineHeight: 1 }}>{value ?? "—"}</div>
|
||
{sub && <div style={{ fontSize: 12, color: "#9ca3af", marginTop: 5 }}>{sub}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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 (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 0", borderBottom: "1px solid #f3f4f6" }}>
|
||
<div style={{ width: 10, height: 10, borderRadius: "50%", background: dotColor, flexShrink: 0, boxShadow: status === "active" ? `0 0 0 3px ${dotColor}33` : "none" }} />
|
||
<div style={{ flex: 1, fontSize: 14, fontWeight: status === "active" ? 700 : 500, color: textColor }}>{label}</div>
|
||
{meta && <div style={{ fontSize: 12, color: "#9ca3af" }}>{meta}</div>}
|
||
{value && (
|
||
<div style={{ background: status === "done" ? "#f0fdf4" : status === "active" ? "#eff6ff" : "#f3f4f6", border: `1px solid ${status === "done" ? "#bbf7d0" : status === "active" ? "#bfdbfe" : "#e5e7eb"}`, borderRadius: 20, padding: "2px 10px", fontSize: 13, fontWeight: 700, color: status === "done" ? "#15803d" : status === "active" ? "#1d4ed8" : "#6b7280" }}>
|
||
{value}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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 (
|
||
<div style={{ display: "flex", gap: 10, padding: "8px 0", borderBottom: "1px solid #f9fafb", alignItems: "flex-start" }}>
|
||
<div style={{ width: 8, height: 8, borderRadius: "50%", background: c.dot, marginTop: 5, flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 700, color: c.text }}>{title}</div>
|
||
<div style={{ fontSize: 12, color: "#6b7280", wordBreak: "break-word", marginTop: 1 }}>{cleanLine}</div>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: "#9ca3af", flexShrink: 0, paddingTop: 1 }}>{fmt(entry.at)}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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 (
|
||
<Page>
|
||
<TitleBar title="Import Dashboard" />
|
||
|
||
{/* ── Header ── */}
|
||
<div style={{ background: "linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)", borderRadius: 16, padding: "28px 32px", marginBottom: 24, color: "#fff", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 16 }}>
|
||
<div>
|
||
<div style={{ fontSize: 22, fontWeight: 800, marginBottom: 4 }}>📊 Import Dashboard</div>
|
||
<div style={{ fontSize: 14, opacity: 0.8 }}>{shop}</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||
<button
|
||
onClick={() => navigate("/app/managebrand")}
|
||
style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.3)", borderRadius: 8, color: "#fff", padding: "8px 18px", cursor: "pointer", fontWeight: 600, fontSize: 14 }}
|
||
>
|
||
+ New Import
|
||
</button>
|
||
{isRunning && (
|
||
<button
|
||
onClick={handleCancel}
|
||
style={{ background: "#dc2626", border: "none", borderRadius: 8, color: "#fff", padding: "8px 18px", cursor: "pointer", fontWeight: 600, fontSize: 14 }}
|
||
>
|
||
Cancel Import
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<Card><div style={{ padding: 48, textAlign: "center" }}><Spinner /><div style={{ marginTop: 12, color: "#6b7280" }}>Loading jobs…</div></div></Card>
|
||
) : jobs.length === 0 ? (
|
||
<Card>
|
||
<div style={{ padding: 64, textAlign: "center" }}>
|
||
<div style={{ fontSize: 48, marginBottom: 12 }}>📦</div>
|
||
<Text variant="headingMd" as="h2">No import jobs yet</Text>
|
||
<div style={{ marginTop: 8, color: "#6b7280", marginBottom: 24 }}>Start an import from the Manage Brands page to see live progress here.</div>
|
||
<Button variant="primary" onClick={() => navigate("/app/managebrand")}>Go to Manage Brands</Button>
|
||
</div>
|
||
</Card>
|
||
) : (
|
||
<Layout>
|
||
{/* ── Job selector sidebar ── */}
|
||
{jobs.length > 1 && (
|
||
<Layout.Section variant="oneThird">
|
||
<Card>
|
||
<div style={{ padding: "12px 16px 8px", borderBottom: "1px solid #f3f4f6" }}>
|
||
<Text variant="headingSm" as="h3" tone="subdued">RECENT IMPORTS</Text>
|
||
</div>
|
||
<div>
|
||
{jobs.slice(0, 10).map(j => (
|
||
<div
|
||
key={j.id}
|
||
onClick={() => 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" }}
|
||
>
|
||
<div style={{ fontWeight: 700, fontSize: 13, color: "#1f2937", marginBottom: 3 }}>{j.brandName || `Brand ${j.brandId}`}</div>
|
||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||
<span style={{ fontSize: 11, color: toneColors[statusTone(j.status)].dot, fontWeight: 600 }}>● {statusLabel(j.status)}</span>
|
||
<span style={{ fontSize: 11, color: "#9ca3af" }}>{fmt(j.startedAt)}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
</Layout.Section>
|
||
)}
|
||
|
||
{/* ── Main job detail ── */}
|
||
<Layout.Section>
|
||
{job && (
|
||
<BlockStack gap="400">
|
||
|
||
{/* Status + Brand header */}
|
||
<Card>
|
||
<BlockStack gap="300">
|
||
<InlineStack align="space-between" blockAlign="center" wrap={false}>
|
||
<BlockStack gap="100">
|
||
<Text variant="headingLg" as="h2">{job.brandName || `Brand ${job.brandId}`}</Text>
|
||
<Text tone="subdued" variant="bodySm">Job ID: {job.id}</Text>
|
||
</BlockStack>
|
||
<InlineStack gap="200" blockAlign="center">
|
||
{isRunning && <Spinner size="small" />}
|
||
<Badge tone={statusTone(job.status)}>{statusLabel(job.status)}</Badge>
|
||
</InlineStack>
|
||
</InlineStack>
|
||
|
||
<Divider />
|
||
|
||
{/* Progress bar */}
|
||
<BlockStack gap="100">
|
||
<InlineStack align="space-between">
|
||
<Text variant="bodySm" tone="subdued">{stepLabel(job.step)}</Text>
|
||
<Text variant="bodyMd" fontWeight="bold">{progress}%</Text>
|
||
</InlineStack>
|
||
<ProgressBar
|
||
progress={progress}
|
||
tone={job.status === "error" ? "critical" : job.status === "done" ? "success" : "highlight"}
|
||
size="medium"
|
||
/>
|
||
</BlockStack>
|
||
|
||
{/* Timing row */}
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12 }}>
|
||
<div style={{ textAlign: "center", padding: "10px 8px", background: "#f9fafb", borderRadius: 8 }}>
|
||
<div style={{ fontSize: 11, color: "#9ca3af", fontWeight: 600, textTransform: "uppercase" }}>Started</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: "#374151", marginTop: 3 }}>{fmt(job.startedAt)}</div>
|
||
</div>
|
||
<div style={{ textAlign: "center", padding: "10px 8px", background: "#eff6ff", borderRadius: 8 }}>
|
||
<div style={{ fontSize: 11, color: "#9ca3af", fontWeight: 600, textTransform: "uppercase" }}>Elapsed</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: "#1d4ed8", marginTop: 3 }}>{isRunning ? elapsed(job.startedAt) : (job.durationSeconds ? `${job.durationSeconds}s` : elapsedStr)}</div>
|
||
</div>
|
||
<div style={{ textAlign: "center", padding: "10px 8px", background: "#f9fafb", borderRadius: 8 }}>
|
||
<div style={{ fontSize: 11, color: "#9ca3af", fontWeight: 600, textTransform: "uppercase" }}>Finished</div>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: "#374151", marginTop: 3 }}>{fmt(job.finishedAt)}</div>
|
||
</div>
|
||
</div>
|
||
</BlockStack>
|
||
</Card>
|
||
|
||
{/* Current product banner */}
|
||
{job.currentProduct && (
|
||
<div style={{ background: "linear-gradient(135deg, #0f172a, #1e3a5f)", borderRadius: 12, padding: "16px 20px", color: "#fff", display: "flex", alignItems: "center", gap: 16 }}>
|
||
<Spinner size="small" />
|
||
<div>
|
||
<div style={{ fontSize: 11, opacity: 0.6, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em" }}>Currently importing</div>
|
||
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 2 }}>{job.currentProduct.name}</div>
|
||
{job.currentProduct.partNumber && (
|
||
<div style={{ fontSize: 12, opacity: 0.7, marginTop: 1 }}>Part: {job.currentProduct.partNumber}</div>
|
||
)}
|
||
<div style={{ fontSize: 12, opacity: 0.6, marginTop: 1 }}>
|
||
Product {job.currentProduct.number} of {job.currentProduct.total}
|
||
</div>
|
||
</div>
|
||
<div style={{ marginLeft: "auto", textAlign: "right" }}>
|
||
<div style={{ fontSize: 32, fontWeight: 900, color: "#60a5fa" }}>{job.currentProduct.number}</div>
|
||
<div style={{ fontSize: 12, opacity: 0.5 }}>/ {job.currentProduct.total}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Stats grid */}
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 12 }}>
|
||
<StatCard label="Total Selected" value={s.total} accent="blue" />
|
||
<StatCard label="Processed" value={s.processed} accent="purple" sub={`${s.remaining} remaining`} />
|
||
<StatCard label="Created" value={s.created} accent="green" />
|
||
<StatCard label="Skipped" value={s.skipped} accent="amber" sub="already in store" />
|
||
<StatCard label="Failed" value={s.failed} accent="red" />
|
||
<StatCard label="Success Rate" value={`${s.successRate}%`} accent={s.successRate >= 90 ? "green" : s.successRate >= 70 ? "amber" : "red"} />
|
||
</div>
|
||
|
||
{/* Stage progress board */}
|
||
<Card>
|
||
<div style={{ padding: "4px 0" }}>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Text variant="headingSm" as="h3">Import Stages</Text>
|
||
</div>
|
||
<StageRow label="Fetch products from Turn14" status={stageStatus("fetching_products")} value={s.total > 0 ? `${s.total} found` : null} />
|
||
<StageRow label="Create / update products in Shopify" status={stageStatus("importing")} value={s.total > 0 ? s.label : null} meta={s.failed > 0 ? `${s.failed} failed` : null} />
|
||
<StageRow label="Import complete" status={stageStatus("completed")} value={job.status === "done" ? "Done" : null} />
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Detail text */}
|
||
{job.detail && (
|
||
<div style={{ background: "#f0f9ff", border: "1px solid #bae6fd", borderRadius: 10, padding: "12px 16px", fontSize: 14, color: "#0369a1", fontWeight: 500 }}>
|
||
ℹ️ {job.detail}
|
||
</div>
|
||
)}
|
||
|
||
{/* Error details */}
|
||
{job.status === "error" && (
|
||
<div style={{ background: "#fff1f2", border: "1px solid #fecdd3", borderRadius: 10, padding: "16px" }}>
|
||
<div style={{ fontWeight: 700, color: "#dc2626", marginBottom: 6 }}>⛔ Import Error</div>
|
||
<div style={{ fontSize: 13, color: "#991b1b" }}>{job.detail}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Per-product errors */}
|
||
{job.errors?.length > 0 && (
|
||
<Card>
|
||
<div style={{ marginBottom: 10 }}>
|
||
<Text variant="headingSm" as="h3" tone="critical">Failed Products ({job.errors.length})</Text>
|
||
</div>
|
||
<div style={{ maxHeight: 220, overflowY: "auto" }}>
|
||
{job.errors.map((e, i) => (
|
||
<div key={i} style={{ padding: "8px 0", borderBottom: "1px solid #fef2f2", display: "flex", gap: 12 }}>
|
||
<div style={{ width: 6, height: 6, borderRadius: "50%", background: "#dc2626", marginTop: 6, flexShrink: 0 }} />
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>#{e.index} {e.product}</div>
|
||
<div style={{ fontSize: 12, color: "#dc2626", marginTop: 2 }}>{e.error}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Completion summary */}
|
||
{job.status === "done" && (
|
||
<div style={{ background: "linear-gradient(135deg, #f0fdf4, #dcfce7)", border: "1px solid #bbf7d0", borderRadius: 12, padding: "20px 24px" }}>
|
||
<div style={{ fontSize: 18, fontWeight: 800, color: "#15803d", marginBottom: 12 }}>✅ Import Completed</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: 10 }}>
|
||
{[
|
||
{ 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 }) => (
|
||
<div key={l} style={{ background: "#fff", borderRadius: 8, padding: "10px 12px", border: "1px solid #bbf7d0" }}>
|
||
<div style={{ fontSize: 11, color: "#6b7280", fontWeight: 600, textTransform: "uppercase" }}>{l}</div>
|
||
<div style={{ fontSize: 20, fontWeight: 800, color: "#15803d", marginTop: 2 }}>{v}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Recent activity log */}
|
||
<Card>
|
||
<div style={{ marginBottom: 10 }}>
|
||
<Text variant="headingSm" as="h3">Recent Activity</Text>
|
||
</div>
|
||
{recentLogs.length === 0 ? (
|
||
<div style={{ color: "#9ca3af", fontSize: 13, padding: "12px 0" }}>No activity yet — waiting for import to start…</div>
|
||
) : (
|
||
recentLogs.map((entry, i) => <LogEntry key={i} entry={entry} />)
|
||
)}
|
||
</Card>
|
||
|
||
</BlockStack>
|
||
)}
|
||
</Layout.Section>
|
||
</Layout>
|
||
)}
|
||
</Page>
|
||
);
|
||
}
|