MOHAN 6b46600fff feat: complete UI/UX rework + live import dashboard
- 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>
2026-06-10 02:23:09 +05:30

489 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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