2026-04-14 01:44:37 +05:30

427 lines
12 KiB
JavaScript

import { useEffect, useMemo, useState } from "react";
import { useFetcher, useLoaderData } from "react-router";
import { useAppBridge } from "@shopify/app-bridge-react";
import { boundary } from "@shopify/shopify-app-react-router/server";
import { authenticate } from "../shopify.server";
import styles from "../styles/app-dashboard.module.css";
function getBackendApiUrl() {
return String(process.env.BACKEND_API_URL || "http://localhost:3002").replace(/\/+$/, "");
}
async function readJsonSafe(response) {
const text = await response.text();
try {
return text ? JSON.parse(text) : null;
} catch {
return { raw: text };
}
}
export const loader = async ({ request }) => {
const { session } = await authenticate.admin(request);
const backendApiUrl = getBackendApiUrl();
const shop = session.shop;
let connection = null;
let currentJob = null;
try {
const response = await fetch(
`${backendApiUrl}/shops/${encodeURIComponent(shop)}`,
);
connection = await readJsonSafe(response);
} catch (error) {
connection = {
status: 0,
message: `Backend unavailable: ${error.message}`,
};
}
try {
const statusResponse = await fetch(
`${backendApiUrl}/pipeline/status/${encodeURIComponent(shop)}`,
);
if (statusResponse.ok) {
currentJob = await readJsonSafe(statusResponse);
}
} catch {
currentJob = null;
}
return {
shop,
backendApiUrl,
connection,
currentJob,
};
};
export const action = async ({ request }) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const backendApiUrl = getBackendApiUrl();
const shop = session.shop;
const limitValue = String(formData.get("limit") || "").trim();
const limit = limitValue ? Number(limitValue) : null;
try {
const response = await fetch(`${backendApiUrl}/pipeline/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
shop,
limit: Number.isFinite(limit) && limit > 0 ? limit : null,
}),
});
const payload = await readJsonSafe(response);
if (!response.ok) {
return {
ok: false,
error: payload?.error || "Failed to start import job.",
};
}
return {
ok: true,
jobId: payload.jobId,
shop,
limit: payload.limit,
backendApiUrl,
};
} catch (error) {
return {
ok: false,
error: error.message,
};
}
};
function FieldState({ fields }) {
if (!fields) {
return <s-text tone="subdued">Your store is not ready yet.</s-text>;
}
return (
<s-stack direction="block" gap="tight">
{Object.entries(fields).map(([key, value]) => (
<s-inline-stack key={key} gap="base" alignItems="center">
<s-text>{key}</s-text>
<s-badge tone={value === "present" ? "success" : "critical"}>
{value}
</s-badge>
</s-inline-stack>
))}
</s-stack>
);
}
function JobSummary({ job }) {
if (!job) {
return (
<div className={styles.emptyPanel}>
<div className={styles.emptyGlow}></div>
<h3>No import in progress</h3>
<p>Start an import to see the live progress and current step here.</p>
</div>
);
}
const progressPercent = Math.max(
0,
Math.min(
100,
Math.round(((job.stepIndex || 0) / (job.totalSteps || 6)) * 100),
),
);
return (
<div className={styles.jobPanel}>
<div className={styles.jobHeader}>
<div>
<p className={styles.eyebrow}>Current import</p>
<h3 className={styles.panelTitle}>{job.id}</h3>
</div>
<span
className={`${styles.statusPill} ${
job.status === "done"
? styles.success
: job.status === "error"
? styles.error
: styles.active
}`}
>
{job.status}
</span>
</div>
<div className={styles.progressMeta}>
<span>{job.step || "-"}</span>
<span>{progressPercent}% complete</span>
</div>
<div className={styles.progressTrack}>
<div
className={styles.progressFill}
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className={styles.statGrid}>
<div className={styles.statCard}>
<span className={styles.statLabel}>Step</span>
<strong>
{job.stepIndex || 0}/{job.totalSteps || 6}
</strong>
</div>
<div className={styles.statCard}>
<span className={styles.statLabel}>Started</span>
<strong>{job.startedAt ? new Date(job.startedAt).toLocaleString() : "-"}</strong>
</div>
<div className={styles.statCard}>
<span className={styles.statLabel}>Updated</span>
<strong>{job.updatedAt ? new Date(job.updatedAt).toLocaleString() : "-"}</strong>
</div>
</div>
<div className={styles.detailCard}>
<span className={styles.statLabel}>Detail</span>
<p>{job.detail || "-"}</p>
</div>
{job.error ? (
<div className={`${styles.detailCard} ${styles.errorCard}`}>
<span className={styles.statLabel}>Error</span>
<p>{job.error}</p>
</div>
) : null}
{job.summary ? (
<div className={styles.jsonCard}>
<span className={styles.statLabel}>Summary</span>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
<code>{JSON.stringify(job.summary, null, 2)}</code>
</pre>
</div>
) : null}
</div>
);
}
export default function Index() {
const { shop, backendApiUrl, connection, currentJob } = useLoaderData();
const fetcher = useFetcher();
const shopify = useAppBridge();
const [job, setJob] = useState(currentJob);
const setupUrl = `${backendApiUrl}/auth/login?shop=${encodeURIComponent(shop)}`;
const isSubmitting =
["loading", "submitting"].includes(fetcher.state) &&
fetcher.formMethod === "POST";
const connectionState = useMemo(() => {
if (connection?.status === 1) {
return "Ready";
}
return "Setup needed";
}, [connection]);
const connectionMessage = useMemo(() => {
if (connection?.status === 1) {
return "Your store is connected and ready for import.";
}
if (String(connection?.message || "").toLowerCase().includes("fetch failed")) {
return "We could not reach the import service right now.";
}
if (String(connection?.message || "").toLowerCase().includes("shop not found")) {
return "This store still needs to be connected before imports can run.";
}
return "This store is not ready yet.";
}, [connection]);
const openSetup = () => {
if (typeof window !== "undefined") {
window.top.location.href = setupUrl;
}
};
useEffect(() => {
if (!fetcher.data) {
return;
}
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");
return;
}
if (fetcher.data.error) {
shopify.toast.show(fetcher.data.error, { isError: true });
}
}, [fetcher.data, shopify]);
useEffect(() => {
if (!job?.id) {
return undefined;
}
let cancelled = false;
async function pollStatus() {
try {
const response = await fetch(
`${backendApiUrl}/pipeline/status/${encodeURIComponent(job.id)}`,
);
const payload = await readJsonSafe(response);
if (!cancelled && response.ok) {
setJob(payload);
}
} catch (error) {
if (!cancelled) {
setJob((current) =>
current
? {
...current,
detail: `Status polling failed: ${error.message}`,
}
: current,
);
}
}
}
pollStatus();
const timer = setInterval(pollStatus, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [backendApiUrl, job?.id]);
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>
<p className={styles.heroText}>
Start an import, follow the progress, and keep track of what is
happening without leaving Shopify.
</p>
</div>
<div className={styles.heroMetrics}>
<div className={styles.metricTile}>
<span className={styles.metricLabel}>Store</span>
<strong>{shop}</strong>
</div>
<div className={styles.metricTile}>
<span className={styles.metricLabel}>Status</span>
<strong>{connectionState}</strong>
</div>
<div className={styles.metricTile}>
<span className={styles.metricLabel}>Import</span>
<strong>{job ? "In progress" : "Not started"}</strong>
</div>
</div>
</div>
<div className={styles.grid}>
<div className={styles.primaryColumn}>
<s-section heading="Store Setup">
<div className={styles.panelBody}>
<div className={styles.inlineRow}>
<span className={styles.statLabel}>Store status</span>
<span
className={`${styles.statusPill} ${
connection?.status === 1 ? styles.success : styles.warning
}`}
>
{connectionState}
</span>
</div>
<div className={styles.detailCard}>
<span className={styles.statLabel}>Message</span>
<p>{connectionMessage}</p>
</div>
{connection?.status !== 1 ? (
<div className={`${styles.detailCard} ${styles.warningCard}`}>
<span className={styles.statLabel}>What to do</span>
<p>Connect your store first, then start the import.</p>
<div className={styles.actionRow}>
<s-button variant="primary" onClick={openSetup}>
Connect store
</s-button>
</div>
</div>
) : (
<div className={`${styles.detailCard} ${styles.successCard}`}>
<span className={styles.statLabel}>Store connection</span>
<p>Your store is ready. You can start importing products now.</p>
</div>
)}
</div>
</s-section>
<s-section
heading="Start Product Import"
description="Choose how much you want to import, then start the process."
>
<fetcher.Form method="post">
<div className={styles.panelBody}>
<s-text-field
label="Product limit"
name="limit"
type="number"
min="1"
details="Leave this empty to import all products, or enter a smaller number for a partial import."
/>
<div className={styles.actionRow}>
<s-button
type="submit"
variant="primary"
{...(isSubmitting ? { loading: true } : {})}
>
Start import
</s-button>
</div>
</div>
</fetcher.Form>
</s-section>
</div>
<div className={styles.secondaryColumn}>
<s-section heading="Import Progress">
<JobSummary job={job} />
</s-section>
<s-section heading="What Happens Next">
<div className={styles.stepList}>
<div className={styles.stepItem}>1. Product information is collected.</div>
<div className={styles.stepItem}>2. Product images are prepared.</div>
<div className={styles.stepItem}>3. Images are uploaded to Shopify.</div>
<div className={styles.stepItem}>4. Products are created or updated in your store.</div>
</div>
</s-section>
</div>
</div>
</s-page>
);
}
export const headers = (headersArgs) => {
return boundary.headers(headersArgs);
};