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(/\/+$/, "");
|
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} />
|
||||||
|
|||||||
@ -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
32
package-lock.json
generated
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user