Add source tabs for import dashboard

This commit is contained in:
MOHAN 2026-05-14 23:57:27 +05:30
parent 932db70910
commit 651fcf44ca
3 changed files with 245 additions and 43 deletions

View File

@ -9,6 +9,12 @@ function getBackendApiUrl() {
return String(process.env.BACKEND_API_URL || "http://localhost:3002").replace(/\/+$/, "");
}
const FALLBACK_SOURCES = [{ sourceKey: "kyt", label: "KYT India" }];
function getJobIdForSource(shop, sourceKey = "kyt") {
return sourceKey === "kyt" ? shop : `${shop}::${sourceKey}`;
}
async function readJsonSafe(response) {
const text = await response.text();
try {
@ -24,7 +30,8 @@ export const loader = async ({ request }) => {
const shop = session.shop;
let connection = null;
let currentJob = null;
let sources = FALLBACK_SOURCES;
let jobsBySource = {};
try {
const response = await fetch(
`${backendApiUrl}/shops/${encodeURIComponent(shop)}`,
@ -38,21 +45,43 @@ export const loader = async ({ request }) => {
}
try {
const statusResponse = await fetch(
`${backendApiUrl}/pipeline/status/${encodeURIComponent(shop)}`,
);
if (statusResponse.ok) {
currentJob = await readJsonSafe(statusResponse);
const sourcesResponse = await fetch(`${backendApiUrl}/pipeline/sources`);
if (sourcesResponse.ok) {
const payload = await readJsonSafe(sourcesResponse);
if (Array.isArray(payload?.sources) && payload.sources.length) {
sources = payload.sources;
}
}
} catch {
currentJob = null;
sources = FALLBACK_SOURCES;
}
await Promise.all(
sources.map(async (source) => {
try {
const jobId = getJobIdForSource(shop, source.sourceKey);
const statusResponse = await fetch(
`${backendApiUrl}/pipeline/status/${encodeURIComponent(jobId)}`,
);
if (statusResponse.ok) {
jobsBySource[source.sourceKey] = await readJsonSafe(statusResponse);
}
} catch {
jobsBySource[source.sourceKey] = null;
}
}),
);
if (!Object.keys(jobsBySource).length) {
jobsBySource = {};
}
return {
shop,
backendApiUrl,
connection,
currentJob,
sources,
jobsBySource,
};
};
@ -62,6 +91,7 @@ export const action = async ({ request }) => {
const backendApiUrl = getBackendApiUrl();
const shop = session.shop;
const limitValue = String(formData.get("limit") || "").trim();
const source = String(formData.get("source") || "kyt").trim() || "kyt";
const limit = limitValue ? Number(limitValue) : null;
try {
@ -72,6 +102,7 @@ export const action = async ({ request }) => {
},
body: JSON.stringify({
shop,
source,
limit: Number.isFinite(limit) && limit > 0 ? limit : null,
}),
});
@ -88,6 +119,7 @@ export const action = async ({ request }) => {
ok: true,
jobId: payload.jobId,
shop,
source: payload.source || source,
limit: payload.limit,
backendApiUrl,
};
@ -163,7 +195,7 @@ function getStageState(job, bucket) {
const STEP_LABELS = {
queued: "Queued",
starting: "Preparing import",
fetchWebsiteData: "Collecting KYT catalog data",
fetchWebsiteData: "Collecting catalog data",
downloadImages: "Downloading product images",
watermarkImages: "Applying watermarks",
uploadImagesToShopifyFiles: "Uploading images to Shopify",
@ -689,15 +721,30 @@ function JobSummary({ job }) {
}
export default function Index() {
const { shop, backendApiUrl, connection, currentJob } = useLoaderData();
const { shop, backendApiUrl, connection, sources, jobsBySource } = useLoaderData();
const fetcher = useFetcher();
const shopify = useAppBridge();
const [job, setJob] = useState(currentJob);
const sourceList = sources?.length ? sources : FALLBACK_SOURCES;
const [activeSource, setActiveSource] = useState(sourceList[0]?.sourceKey || "kyt");
const [sourceJobs, setSourceJobs] = useState(jobsBySource || {});
const activeSourceConfig =
sourceList.find((source) => source.sourceKey === activeSource) || sourceList[0] || FALLBACK_SOURCES[0];
const job = sourceJobs[activeSourceConfig.sourceKey] || null;
const activeJobId = job?.id || null;
const setupUrl = `${backendApiUrl}/auth/login?shop=${encodeURIComponent(shop)}`;
const isSubmitting =
["loading", "submitting"].includes(fetcher.state) &&
fetcher.formMethod === "POST";
fetcher.formMethod === "POST" &&
String(fetcher.formData?.get("source") || "") === activeSourceConfig.sourceKey;
const runningSources = useMemo(() => {
return new Set(
Object.entries(sourceJobs || {})
.filter(([, sourceJob]) => sourceJob && !["done", "error"].includes(sourceJob.status))
.map(([sourceKey]) => sourceKey),
);
}, [sourceJobs]);
const connectionState = useMemo(() => {
if (connection?.status === 1) {
@ -734,25 +781,40 @@ export default function Index() {
}
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");
const sourceKey = fetcher.data.source || activeSourceConfig.sourceKey;
setActiveSource(sourceKey);
setSourceJobs((current) => ({
...current,
[sourceKey]: {
id: fetcher.data.jobId,
status: "queued",
step: "queued",
stepIndex: 0,
totalSteps: 6,
detail: "Job queued",
payload: {
source: sourceKey,
shop,
limit: fetcher.data.limit,
},
},
}));
const sourceLabel =
sourceList.find((source) => source.sourceKey === sourceKey)?.label || sourceKey;
shopify.toast.show(`${sourceLabel} import job started`);
return;
}
if (fetcher.data.error) {
shopify.toast.show(fetcher.data.error, { isError: true });
}
}, [fetcher.data, shopify]);
}, [activeSourceConfig.sourceKey, fetcher.data, shop, shopify, sourceList]);
useEffect(() => {
if (!job?.id) {
const sourceKey = activeSourceConfig.sourceKey;
const jobId = activeJobId;
if (!jobId) {
return undefined;
}
@ -761,22 +823,26 @@ export default function Index() {
async function pollStatus() {
try {
const response = await fetch(
`${backendApiUrl}/pipeline/status/${encodeURIComponent(job.id)}`,
`${backendApiUrl}/pipeline/status/${encodeURIComponent(jobId)}`,
);
const payload = await readJsonSafe(response);
if (!cancelled && response.ok) {
setJob(payload);
setSourceJobs((current) => ({
...current,
[sourceKey]: payload,
}));
}
} catch (error) {
if (!cancelled) {
setJob((current) =>
current
setSourceJobs((current) => ({
...current,
[sourceKey]: current[sourceKey]
? {
...current,
...current[sourceKey],
detail: `Status polling failed: ${error.message}`,
}
: current,
);
: current[sourceKey],
}));
}
}
}
@ -788,17 +854,17 @@ export default function Index() {
cancelled = true;
clearInterval(timer);
};
}, [backendApiUrl, job?.id]);
}, [activeJobId, activeSourceConfig.sourceKey, backendApiUrl, shop]);
return (
<s-page heading="Race Nation Imports">
<div className={styles.hero}>
<div className={styles.heroCopy}>
<p className={styles.kicker}>Import Center</p>
<h2>Bring KYT products into your store from one simple dashboard.</h2>
<h2>Manage every product source from one import dashboard.</h2>
<p className={styles.heroText}>
Start an import, follow the progress, and keep track of what is
happening without leaving Shopify.
Pick a source, start its import, and follow the progress without
leaving Shopify.
</p>
</div>
<div className={styles.heroMetrics}>
@ -811,13 +877,48 @@ export default function Index() {
<strong>{connectionState}</strong>
</div>
<div className={styles.metricTile}>
<span className={styles.metricLabel}>Import</span>
<strong>{job ? "In progress" : "Not started"}</strong>
<span className={styles.metricLabel}>Active source</span>
<strong>{activeSourceConfig.label}</strong>
</div>
</div>
</div>
<div className={styles.dashboardShell}>
<div className={styles.sourceTabsShell}>
<div className={styles.sourceTabs} role="tablist" aria-label="Import sources">
{sourceList.map((source) => {
const sourceJob = sourceJobs[source.sourceKey];
const isActive = source.sourceKey === activeSourceConfig.sourceKey;
const isRunning = runningSources.has(source.sourceKey);
return (
<button
key={source.sourceKey}
type="button"
role="tab"
aria-selected={isActive}
className={`${styles.sourceTab} ${isActive ? styles.sourceTabActive : ""}`}
onClick={() => setActiveSource(source.sourceKey)}
>
<span className={styles.sourceTabTitle}>{source.label}</span>
<span
className={`${styles.sourceTabStatus} ${
sourceJob?.status === "done"
? styles.sourceTabDone
: sourceJob?.status === "error"
? styles.sourceTabError
: isRunning
? styles.sourceTabRunning
: ""
}`}
>
{sourceJob?.status || "ready"}
</span>
</button>
);
})}
</div>
</div>
<div className={styles.controlRow}>
<div className={styles.controlCard}>
<div className={styles.controlHeader}>
@ -847,21 +948,23 @@ export default function Index() {
<div className={styles.controlHeader}>
<div>
<span className={styles.statLabel}>Import action</span>
<h3 className={styles.controlTitle}>Start product import</h3>
<h3 className={styles.controlTitle}>{activeSourceConfig.label} import</h3>
</div>
</div>
<p className={styles.controlText}>
Launch the full Race Nation import for this store and track the live sync below.
Launch this source independently and track its live sync below.
</p>
<fetcher.Form method="post">
<input type="hidden" name="source" value={activeSourceConfig.sourceKey} />
<div className={styles.controlForm}>
<div className={styles.actionRow}>
<s-button
type="submit"
variant="primary"
{...(connection?.status !== 1 ? { disabled: true } : {})}
{...(isSubmitting ? { loading: true } : {})}
>
Start import
Start {activeSourceConfig.label}
</s-button>
</div>
</div>
@ -873,7 +976,7 @@ export default function Index() {
<div className={styles.mainPanelHeader}>
<div>
<span className={styles.statLabel}>Live dashboard</span>
<h3 className={styles.controlTitle}>Import progress</h3>
<h3 className={styles.controlTitle}>{activeSourceConfig.label} progress</h3>
</div>
</div>
<JobSummary job={job} />

View File

@ -89,6 +89,79 @@
gap: 1.25rem;
}
.sourceTabsShell {
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
overflow-x: auto;
}
.sourceTabs {
display: flex;
align-items: flex-end;
gap: 0.35rem;
min-width: max-content;
padding: 0 0.25rem;
}
.sourceTab {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.65rem;
min-height: 2.75rem;
padding: 0.65rem 1rem 0.7rem;
border: 1px solid rgba(148, 163, 184, 0.26);
border-bottom-color: transparent;
border-radius: 12px 12px 0 0;
background: rgba(241, 245, 249, 0.72);
color: #334155;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.sourceTab:hover {
background: rgba(255, 255, 255, 0.92);
}
.sourceTabActive {
background: #ffffff;
color: #0f172a;
box-shadow: 0 -10px 26px rgba(15, 23, 42, 0.07);
}
.sourceTabTitle {
white-space: nowrap;
}
.sourceTabStatus {
display: inline-flex;
align-items: center;
min-height: 1.35rem;
padding: 0.12rem 0.45rem;
border-radius: 999px;
background: rgba(100, 116, 139, 0.12);
color: #475569;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sourceTabRunning {
background: rgba(14, 165, 233, 0.14);
color: #075985;
}
.sourceTabDone {
background: rgba(22, 163, 74, 0.14);
color: #166534;
}
.sourceTabError {
background: rgba(239, 68, 68, 0.16);
color: #991b1b;
}
.controlRow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

32
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@shopify/app-bridge-react": "^4.2.4",
"@shopify/shopify-app-react-router": "^1.1.0",
"@shopify/shopify-app-session-storage-prisma": "^8.0.0",
"dotenv": "^17.2.0",
"isbot": "^5.1.31",
"prisma": "^6.16.3",
"react": "^18.3.1",
@ -2346,6 +2347,19 @@
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@graphql-tools/prisma-loader/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@graphql-tools/relay-operation-optimizer": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.2.tgz",
@ -5168,6 +5182,18 @@
}
}
},
"node_modules/c12/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/c12/node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -6002,9 +6028,9 @@
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"