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(/\/+$/, ""); 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) { async function readJsonSafe(response) {
const text = await response.text(); const text = await response.text();
try { try {
@ -24,7 +30,8 @@ export const loader = async ({ request }) => {
const shop = session.shop; const shop = session.shop;
let connection = null; let connection = null;
let currentJob = null; let sources = FALLBACK_SOURCES;
let jobsBySource = {};
try { try {
const response = await fetch( const response = await fetch(
`${backendApiUrl}/shops/${encodeURIComponent(shop)}`, `${backendApiUrl}/shops/${encodeURIComponent(shop)}`,
@ -38,21 +45,43 @@ export const loader = async ({ request }) => {
} }
try { try {
const statusResponse = await fetch( const sourcesResponse = await fetch(`${backendApiUrl}/pipeline/sources`);
`${backendApiUrl}/pipeline/status/${encodeURIComponent(shop)}`, if (sourcesResponse.ok) {
); const payload = await readJsonSafe(sourcesResponse);
if (statusResponse.ok) { if (Array.isArray(payload?.sources) && payload.sources.length) {
currentJob = await readJsonSafe(statusResponse); sources = payload.sources;
}
} }
} catch { } 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 { return {
shop, shop,
backendApiUrl, backendApiUrl,
connection, connection,
currentJob, sources,
jobsBySource,
}; };
}; };
@ -62,6 +91,7 @@ export const action = async ({ request }) => {
const backendApiUrl = getBackendApiUrl(); const backendApiUrl = getBackendApiUrl();
const shop = session.shop; const shop = session.shop;
const limitValue = String(formData.get("limit") || "").trim(); const limitValue = String(formData.get("limit") || "").trim();
const source = String(formData.get("source") || "kyt").trim() || "kyt";
const limit = limitValue ? Number(limitValue) : null; const limit = limitValue ? Number(limitValue) : null;
try { try {
@ -72,6 +102,7 @@ export const action = async ({ request }) => {
}, },
body: JSON.stringify({ body: JSON.stringify({
shop, shop,
source,
limit: Number.isFinite(limit) && limit > 0 ? limit : null, limit: Number.isFinite(limit) && limit > 0 ? limit : null,
}), }),
}); });
@ -88,6 +119,7 @@ export const action = async ({ request }) => {
ok: true, ok: true,
jobId: payload.jobId, jobId: payload.jobId,
shop, shop,
source: payload.source || source,
limit: payload.limit, limit: payload.limit,
backendApiUrl, backendApiUrl,
}; };
@ -163,7 +195,7 @@ function getStageState(job, bucket) {
const STEP_LABELS = { const STEP_LABELS = {
queued: "Queued", queued: "Queued",
starting: "Preparing import", starting: "Preparing import",
fetchWebsiteData: "Collecting KYT catalog data", fetchWebsiteData: "Collecting catalog data",
downloadImages: "Downloading product images", downloadImages: "Downloading product images",
watermarkImages: "Applying watermarks", watermarkImages: "Applying watermarks",
uploadImagesToShopifyFiles: "Uploading images to Shopify", uploadImagesToShopifyFiles: "Uploading images to Shopify",
@ -689,15 +721,30 @@ function JobSummary({ job }) {
} }
export default function Index() { export default function Index() {
const { shop, backendApiUrl, connection, currentJob } = useLoaderData(); const { shop, backendApiUrl, connection, sources, jobsBySource } = useLoaderData();
const fetcher = useFetcher(); const fetcher = useFetcher();
const shopify = useAppBridge(); 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 setupUrl = `${backendApiUrl}/auth/login?shop=${encodeURIComponent(shop)}`;
const isSubmitting = const isSubmitting =
["loading", "submitting"].includes(fetcher.state) && ["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(() => { const connectionState = useMemo(() => {
if (connection?.status === 1) { if (connection?.status === 1) {
@ -734,25 +781,40 @@ export default function Index() {
} }
if (fetcher.data.ok && fetcher.data.jobId) { if (fetcher.data.ok && fetcher.data.jobId) {
setJob({ const sourceKey = fetcher.data.source || activeSourceConfig.sourceKey;
id: fetcher.data.jobId, setActiveSource(sourceKey);
status: "queued", setSourceJobs((current) => ({
step: "queued", ...current,
stepIndex: 0, [sourceKey]: {
totalSteps: 6, id: fetcher.data.jobId,
detail: "Job queued", status: "queued",
}); step: "queued",
shopify.toast.show("KYT import job started"); 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; return;
} }
if (fetcher.data.error) { if (fetcher.data.error) {
shopify.toast.show(fetcher.data.error, { isError: true }); shopify.toast.show(fetcher.data.error, { isError: true });
} }
}, [fetcher.data, shopify]); }, [activeSourceConfig.sourceKey, fetcher.data, shop, shopify, sourceList]);
useEffect(() => { useEffect(() => {
if (!job?.id) { const sourceKey = activeSourceConfig.sourceKey;
const jobId = activeJobId;
if (!jobId) {
return undefined; return undefined;
} }
@ -761,22 +823,26 @@ export default function Index() {
async function pollStatus() { async function pollStatus() {
try { try {
const response = await fetch( const response = await fetch(
`${backendApiUrl}/pipeline/status/${encodeURIComponent(job.id)}`, `${backendApiUrl}/pipeline/status/${encodeURIComponent(jobId)}`,
); );
const payload = await readJsonSafe(response); const payload = await readJsonSafe(response);
if (!cancelled && response.ok) { if (!cancelled && response.ok) {
setJob(payload); setSourceJobs((current) => ({
...current,
[sourceKey]: payload,
}));
} }
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
setJob((current) => setSourceJobs((current) => ({
current ...current,
[sourceKey]: current[sourceKey]
? { ? {
...current, ...current[sourceKey],
detail: `Status polling failed: ${error.message}`, detail: `Status polling failed: ${error.message}`,
} }
: current, : current[sourceKey],
); }));
} }
} }
} }
@ -788,17 +854,17 @@ export default function Index() {
cancelled = true; cancelled = true;
clearInterval(timer); clearInterval(timer);
}; };
}, [backendApiUrl, job?.id]); }, [activeJobId, activeSourceConfig.sourceKey, backendApiUrl, shop]);
return ( return (
<s-page heading="Race Nation Imports"> <s-page heading="Race Nation Imports">
<div className={styles.hero}> <div className={styles.hero}>
<div className={styles.heroCopy}> <div className={styles.heroCopy}>
<p className={styles.kicker}>Import Center</p> <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}> <p className={styles.heroText}>
Start an import, follow the progress, and keep track of what is Pick a source, start its import, and follow the progress without
happening without leaving Shopify. leaving Shopify.
</p> </p>
</div> </div>
<div className={styles.heroMetrics}> <div className={styles.heroMetrics}>
@ -811,13 +877,48 @@ export default function Index() {
<strong>{connectionState}</strong> <strong>{connectionState}</strong>
</div> </div>
<div className={styles.metricTile}> <div className={styles.metricTile}>
<span className={styles.metricLabel}>Import</span> <span className={styles.metricLabel}>Active source</span>
<strong>{job ? "In progress" : "Not started"}</strong> <strong>{activeSourceConfig.label}</strong>
</div> </div>
</div> </div>
</div> </div>
<div className={styles.dashboardShell}> <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.controlRow}>
<div className={styles.controlCard}> <div className={styles.controlCard}>
<div className={styles.controlHeader}> <div className={styles.controlHeader}>
@ -847,21 +948,23 @@ export default function Index() {
<div className={styles.controlHeader}> <div className={styles.controlHeader}>
<div> <div>
<span className={styles.statLabel}>Import action</span> <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>
</div> </div>
<p className={styles.controlText}> <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> </p>
<fetcher.Form method="post"> <fetcher.Form method="post">
<input type="hidden" name="source" value={activeSourceConfig.sourceKey} />
<div className={styles.controlForm}> <div className={styles.controlForm}>
<div className={styles.actionRow}> <div className={styles.actionRow}>
<s-button <s-button
type="submit" type="submit"
variant="primary" variant="primary"
{...(connection?.status !== 1 ? { disabled: true } : {})}
{...(isSubmitting ? { loading: true } : {})} {...(isSubmitting ? { loading: true } : {})}
> >
Start import Start {activeSourceConfig.label}
</s-button> </s-button>
</div> </div>
</div> </div>
@ -873,7 +976,7 @@ export default function Index() {
<div className={styles.mainPanelHeader}> <div className={styles.mainPanelHeader}>
<div> <div>
<span className={styles.statLabel}>Live dashboard</span> <span className={styles.statLabel}>Live dashboard</span>
<h3 className={styles.controlTitle}>Import progress</h3> <h3 className={styles.controlTitle}>{activeSourceConfig.label} progress</h3>
</div> </div>
</div> </div>
<JobSummary job={job} /> <JobSummary job={job} />

View File

@ -89,6 +89,79 @@
gap: 1.25rem; 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 { .controlRow {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); 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/app-bridge-react": "^4.2.4",
"@shopify/shopify-app-react-router": "^1.1.0", "@shopify/shopify-app-react-router": "^1.1.0",
"@shopify/shopify-app-session-storage-prisma": "^8.0.0", "@shopify/shopify-app-session-storage-prisma": "^8.0.0",
"dotenv": "^17.2.0",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"prisma": "^6.16.3", "prisma": "^6.16.3",
"react": "^18.3.1", "react": "^18.3.1",
@ -2346,6 +2347,19 @@
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" "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": { "node_modules/@graphql-tools/relay-operation-optimizer": {
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.1.2.tgz", "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": { "node_modules/c12/node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -6002,9 +6028,9 @@
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"