From 651fcf44cadf0c4b7e081178ca6ab8a6d5e17735 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 14 May 2026 23:57:27 +0530 Subject: [PATCH] Add source tabs for import dashboard --- app/routes/app._index.jsx | 183 ++++++++++++++++++++++------ app/styles/app-dashboard.module.css | 73 +++++++++++ package-lock.json | 32 ++++- 3 files changed, 245 insertions(+), 43 deletions(-) diff --git a/app/routes/app._index.jsx b/app/routes/app._index.jsx index 66c4fb0..0bda272 100644 --- a/app/routes/app._index.jsx +++ b/app/routes/app._index.jsx @@ -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 (

Import Center

-

Bring KYT products into your store from one simple dashboard.

+

Manage every product source from one import dashboard.

- 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.

@@ -811,13 +877,48 @@ export default function Index() { {connectionState}
- Import - {job ? "In progress" : "Not started"} + Active source + {activeSourceConfig.label}
+
+
+ {sourceList.map((source) => { + const sourceJob = sourceJobs[source.sourceKey]; + const isActive = source.sourceKey === activeSourceConfig.sourceKey; + const isRunning = runningSources.has(source.sourceKey); + return ( + + ); + })} +
+
+
@@ -847,21 +948,23 @@ export default function Index() {
Import action -

Start product import

+

{activeSourceConfig.label} import

- 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.

+
- Start import + Start {activeSourceConfig.label}
@@ -873,7 +976,7 @@ export default function Index() {
Live dashboard -

Import progress

+

{activeSourceConfig.label} progress

diff --git a/app/styles/app-dashboard.module.css b/app/styles/app-dashboard.module.css index 8dcde78..bb4595e 100644 --- a/app/styles/app-dashboard.module.css +++ b/app/styles/app-dashboard.module.css @@ -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)); diff --git a/package-lock.json b/package-lock.json index 06a63c5..15ba656 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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"