Add source tabs for import dashboard
This commit is contained in:
parent
932db70910
commit
651fcf44ca
@ -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} />
|
||||
|
||||
@ -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
32
package-lock.json
generated
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user