1119 lines
40 KiB
JavaScript
1119 lines
40 KiB
JavaScript
import React, { useEffect, useState } from "react";
|
||
import { json } from "@remix-run/node";
|
||
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||
import {
|
||
Page,
|
||
Layout,
|
||
IndexTable,
|
||
Card,
|
||
Thumbnail,
|
||
TextContainer,
|
||
Spinner,
|
||
Button,
|
||
TextField,
|
||
Banner,
|
||
InlineError,
|
||
} from "@shopify/polaris";
|
||
import { authenticate } from "../shopify.server";
|
||
import { TitleBar } from "@shopify/app-bridge-react";
|
||
|
||
export const loader = async ({ request }) => {
|
||
const { admin } = await authenticate.admin(request);
|
||
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
|
||
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||
|
||
const res = await admin.graphql(`{
|
||
shop {
|
||
metafield(namespace: "turn14", key: "selected_brands") {
|
||
value
|
||
}
|
||
}
|
||
}`);
|
||
const data = await res.json();
|
||
const rawValue = data?.data?.shop?.metafield?.value;
|
||
|
||
let brands = [];
|
||
try {
|
||
brands = JSON.parse(rawValue);
|
||
} catch (err) {
|
||
console.error("❌ Failed to parse metafield value:", err);
|
||
}
|
||
|
||
return json({ brands, accessToken });
|
||
};
|
||
|
||
// export const action = async ({ request }) => {
|
||
// const { admin } = await authenticate.admin(request);
|
||
// const formData = await request.formData();
|
||
// const brandId = formData.get("brandId");
|
||
// const rawCount = formData.get("productCount");
|
||
// const productCount = parseInt(rawCount, 10) || 10;
|
||
|
||
// const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
|
||
// const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||
|
||
// // Fetch items from Turn14 API
|
||
// const itemsRes = await fetch(
|
||
// `https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`,
|
||
// {
|
||
// headers: {
|
||
// Authorization: `Bearer ${accessToken}`,
|
||
// "Content-Type": "application/json",
|
||
// },
|
||
// }
|
||
// );
|
||
// const itemsData = await itemsRes.json();
|
||
|
||
// function slugify(str) {
|
||
// return str
|
||
// .toString()
|
||
// .trim()
|
||
// .toLowerCase()
|
||
// .replace(/[^a-z0-9]+/g, '-')
|
||
// .replace(/^-+|-+$/g, '');
|
||
// }
|
||
|
||
// const items = Array.isArray(itemsData) ? itemsData.slice(0, productCount) : [];
|
||
// const results = [];
|
||
|
||
// for (const item of items) {
|
||
// const attrs = item.attributes;
|
||
|
||
// // Build and normalize collection titles
|
||
// const category = attrs.category;
|
||
// const subcategory = attrs.subcategory || "";
|
||
|
||
// const brand = attrs.brand;
|
||
// const subcats = subcategory
|
||
// .split(/[,\/]/)
|
||
// .map((s) => s.trim())
|
||
// .filter(Boolean);
|
||
// const collectionTitles = Array.from(
|
||
// new Set([category, ...subcats, brand].filter(Boolean))
|
||
// );
|
||
|
||
// // Find or create collections, collect their IDs
|
||
// const collectionIds = [];
|
||
// for (const title of collectionTitles) {
|
||
// const lookupRes = await admin.graphql(`
|
||
// {
|
||
// collections(first: 1, query: "title:\\"${title}\\" AND collection_type:manual") {
|
||
// nodes { id }
|
||
// }
|
||
// }
|
||
// `);
|
||
// const lookupJson = await lookupRes.json();
|
||
// const existing = lookupJson.data.collections.nodes;
|
||
// if (existing.length) {
|
||
// collectionIds.push(existing[0].id);
|
||
// } else {
|
||
// const createColRes = await admin.graphql(`
|
||
// mutation($input: CollectionInput!) {
|
||
// collectionCreate(input: $input) {
|
||
// collection { id }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, { variables: { input: { title } } });
|
||
// const createColJson = await createColRes.json();
|
||
// const errs = createColJson.data.collectionCreate.userErrors;
|
||
// if (errs.length) {
|
||
// throw new Error(`Could not create collection "${title}": ${errs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
// collectionIds.push(createColJson.data.collectionCreate.collection.id);
|
||
// }
|
||
// }
|
||
|
||
// // Build tags
|
||
// const tags = [
|
||
// attrs.category,
|
||
// ...subcats,
|
||
// attrs.brand,
|
||
// attrs.part_number,
|
||
// attrs.mfr_part_number,
|
||
// attrs.price_group,
|
||
// attrs.units_per_sku && `${attrs.units_per_sku} per SKU`,
|
||
// attrs.barcode
|
||
// ].filter(Boolean).map((t) => t.trim());
|
||
|
||
// // Prepare media inputs
|
||
// const mediaInputs = (attrs.files || [])
|
||
// .filter((f) => f.type === "Image" && f.url)
|
||
// .map((file) => ({
|
||
// originalSource: file.url,
|
||
// mediaContentType: "IMAGE",
|
||
// alt: `${attrs.product_name} — ${file.media_content}`,
|
||
// }));
|
||
|
||
// // Pick the longest "Market Description" or fallback to part_description
|
||
// const marketDescs = (attrs.descriptions || [])
|
||
// .filter((d) => d.type === "Market Description")
|
||
// .map((d) => d.description);
|
||
// const descriptionHtml = marketDescs.length
|
||
// ? marketDescs.reduce((a, b) => (b.length > a.length ? b : a))
|
||
// : attrs.part_description;
|
||
|
||
// // Create product + attach to collections + add media
|
||
// const createProdRes = await admin.graphql(`
|
||
// mutation($prod: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||
// productCreate(product: $prod, media: $media) {
|
||
// product {
|
||
// id
|
||
// variants(first: 1) {
|
||
// nodes { id inventoryItem { id } }
|
||
// }
|
||
// }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, {
|
||
// variables: {
|
||
// prod: {
|
||
// title: attrs.product_name,
|
||
// descriptionHtml: descriptionHtml,
|
||
// vendor: attrs.brand,
|
||
// productType: attrs.category,
|
||
// handle: slugify(attrs.part_number || attrs.product_name),
|
||
// tags,
|
||
// collectionsToJoin: collectionIds,
|
||
// status: "ACTIVE",
|
||
// },
|
||
// media: mediaInputs,
|
||
// },
|
||
// });
|
||
// const createProdJson = await createProdRes.json();
|
||
// const prodErrs = createProdJson.data.productCreate.userErrors;
|
||
// if (prodErrs.length) {
|
||
// const taken = prodErrs.some((e) => /already in use/i.test(e.message));
|
||
// if (taken) {
|
||
// results.push({ skippedHandle: attrs.part_number, reason: "handle in use" });
|
||
// continue;
|
||
// }
|
||
// throw new Error(`ProductCreate errors: ${prodErrs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
|
||
// const product = createProdJson.data.productCreate.product;
|
||
// const variantNode = product.variants.nodes[0];
|
||
// const variantId = variantNode.id;
|
||
// const inventoryItemId = variantNode.inventoryItem.id;
|
||
|
||
// // Bulk-update variant (price, compare-at, barcode)
|
||
// const price = parseFloat(attrs.price) || 1000;
|
||
// const comparePrice = parseFloat(attrs.compare_price) || null;
|
||
// const barcode = attrs.barcode || "";
|
||
|
||
// const bulkRes = await admin.graphql(`
|
||
// mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||
// productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||
// productVariants { id price compareAtPrice barcode }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, {
|
||
// variables: {
|
||
// productId: product.id,
|
||
// variants: [{
|
||
// id: variantId,
|
||
// price,
|
||
// ...(comparePrice !== null && { compareAtPrice: comparePrice }),
|
||
// ...(barcode && { barcode }),
|
||
// }],
|
||
// },
|
||
// });
|
||
// const bulkJson = await bulkRes.json();
|
||
// const bulkErrs = bulkJson.data.productVariantsBulkUpdate.userErrors;
|
||
// if (bulkErrs.length) {
|
||
// throw new Error(`Bulk update errors: ${bulkErrs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
// const updatedVariant = bulkJson.data.productVariantsBulkUpdate.productVariants[0];
|
||
|
||
// // Update inventory item (SKU, cost & weight)
|
||
// const costPerItem = parseFloat(attrs.purchase_cost) || 0;
|
||
// const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0;
|
||
|
||
// const invRes = await admin.graphql(`
|
||
// mutation($id: ID!, $input: InventoryItemInput!) {
|
||
// inventoryItemUpdate(id: $id, input: $input) {
|
||
// inventoryItem {
|
||
// id
|
||
// sku
|
||
// measurement {
|
||
// weight { value unit }
|
||
// }
|
||
// }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, {
|
||
// variables: {
|
||
// id: inventoryItemId,
|
||
// input: {
|
||
// sku: attrs.part_number,
|
||
// cost: costPerItem,
|
||
// measurement: {
|
||
// weight: { value: weightValue, unit: "POUNDS" }
|
||
// },
|
||
// },
|
||
// },
|
||
// });
|
||
// const invJson = await invRes.json();
|
||
// const invErrs = invJson.data.inventoryItemUpdate.userErrors;
|
||
// if (invErrs.length) {
|
||
// throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
// const inventoryItem = invJson.data.inventoryItemUpdate.inventoryItem;
|
||
|
||
// // Collect results
|
||
// results.push({
|
||
// productId: product.id,
|
||
// variant: {
|
||
// id: updatedVariant.id,
|
||
// price: updatedVariant.price,
|
||
// compareAtPrice: updatedVariant.compareAtPrice,
|
||
// sku: inventoryItem.sku,
|
||
// barcode: updatedVariant.barcode,
|
||
// weight: inventoryItem.measurement.weight.value,
|
||
// weightUnit: inventoryItem.measurement.weight.unit,
|
||
// },
|
||
// collections: collectionTitles,
|
||
// tags,
|
||
// });
|
||
// }
|
||
|
||
// return json({ success: true, results });
|
||
// };
|
||
|
||
// export const action = async ({ request }) => {
|
||
// const { admin } = await authenticate.admin(request);
|
||
// const formData = await request.formData();
|
||
// const brandId = formData.get("brandId");
|
||
// const rawCount = formData.get("productCount");
|
||
// const productCount = parseInt(rawCount, 10) || 10;
|
||
|
||
// const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
|
||
// const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||
|
||
// // Fetch items from Turn14 API
|
||
// console.log("Fetching items from Turn14 API...");
|
||
// const itemsRes = await fetch(
|
||
// `https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`,
|
||
// {
|
||
// headers: {
|
||
// Authorization: `Bearer ${accessToken}`,
|
||
// "Content-Type": "application/json",
|
||
// },
|
||
// }
|
||
// );
|
||
// const itemsData = await itemsRes.json();
|
||
// console.log("Items data fetched:", itemsData);
|
||
|
||
// function slugify(str) {
|
||
// return str
|
||
// .toString()
|
||
// .trim()
|
||
// .toLowerCase()
|
||
// .replace(/[^a-z0-9]+/g, '-')
|
||
// .replace(/^-+|-+$/g, '');
|
||
// }
|
||
|
||
// const items = Array.isArray(itemsData) ? itemsData.slice(0, productCount) : [];
|
||
// console.log(`Processing ${items.length} items...`);
|
||
// const results = [];
|
||
|
||
// for (const item of items) {
|
||
// const attrs = item.attributes;
|
||
// console.log("Processing item:", attrs);
|
||
|
||
// // Build and normalize collection titles
|
||
// const category = attrs.category;
|
||
// const subcategory = attrs.subcategory || "";
|
||
// const brand = attrs.brand;
|
||
// const subcats = subcategory
|
||
// .split(/[,\/]/)
|
||
// .map((s) => s.trim())
|
||
// .filter(Boolean);
|
||
// const collectionTitles = Array.from(
|
||
// new Set([category, ...subcats, brand].filter(Boolean))
|
||
// );
|
||
// console.log("Collection Titles:", collectionTitles);
|
||
|
||
// // Find or create collections, collect their IDs
|
||
// const collectionIds = [];
|
||
// for (const title of collectionTitles) {
|
||
// console.log(`Searching for collection with title: ${title}`);
|
||
// const lookupRes = await admin.graphql(`
|
||
// {
|
||
// collections(first: 1, query: "title:\\"${title}\\" AND collection_type:manual") {
|
||
// nodes { id }
|
||
// }
|
||
// }
|
||
// `);
|
||
// const lookupJson = await lookupRes.json();
|
||
// console.log("Lookup response for collections:", lookupJson);
|
||
|
||
// const existing = lookupJson.data.collections.nodes;
|
||
// if (existing.length) {
|
||
// console.log(`Found existing collection for title: ${title}`);
|
||
// collectionIds.push(existing[0].id);
|
||
// } else {
|
||
// console.log(`Creating new collection for title: ${title}`);
|
||
// const createColRes = await admin.graphql(`
|
||
// mutation($input: CollectionInput!) {
|
||
// collectionCreate(input: $input) {
|
||
// collection { id }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, { variables: { input: { title } } });
|
||
// const createColJson = await createColRes.json();
|
||
// console.log("Create collection response:", createColJson);
|
||
|
||
// const errs = createColJson.data.collectionCreate.userErrors;
|
||
// if (errs.length) {
|
||
// throw new Error(`Could not create collection "${title}": ${errs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
// collectionIds.push(createColJson.data.collectionCreate.collection.id);
|
||
// }
|
||
// }
|
||
|
||
// // Build tags
|
||
// const tags = [
|
||
// attrs.category,
|
||
// ...subcats,
|
||
// attrs.brand,
|
||
// attrs.part_number,
|
||
// attrs.mfr_part_number,
|
||
// attrs.price_group,
|
||
// attrs.units_per_sku && `${attrs.units_per_sku} per SKU`,
|
||
// attrs.barcode
|
||
// ].filter(Boolean).map((t) => t.trim());
|
||
// console.log("Tags:", tags);
|
||
|
||
// // Prepare media inputs
|
||
// const mediaInputs = (attrs.files || [])
|
||
// .filter((f) => f.type === "Image" && f.url)
|
||
// .map((file) => ({
|
||
// originalSource: file.url,
|
||
// mediaContentType: "IMAGE",
|
||
// alt: `${attrs.product_name} — ${file.media_content}`,
|
||
// }));
|
||
// console.log("Media inputs:", mediaInputs);
|
||
|
||
// // Pick the longest "Market Description" or fallback to part_description
|
||
// const marketDescs = (attrs.descriptions || [])
|
||
// .filter((d) => d.type === "Market Description")
|
||
// .map((d) => d.description);
|
||
// const descriptionHtml = marketDescs.length
|
||
// ? marketDescs.reduce((a, b) => (b.length > a.length ? b : a))
|
||
// : attrs.part_description;
|
||
// console.log("Description HTML:", descriptionHtml);
|
||
|
||
// // Create product + attach to collections + add media
|
||
// const createProdRes = await admin.graphql(`
|
||
// mutation($prod: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||
// productCreate(product: $prod, media: $media) {
|
||
// product {
|
||
// id
|
||
// variants(first: 1) {
|
||
// nodes { id inventoryItem { id } }
|
||
// }
|
||
// }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, {
|
||
// variables: {
|
||
// prod: {
|
||
// title: attrs.product_name,
|
||
// descriptionHtml: descriptionHtml,
|
||
// vendor: attrs.brand,
|
||
// productType: attrs.category,
|
||
// handle: slugify(attrs.part_number || attrs.product_name),
|
||
// tags,
|
||
// collectionsToJoin: collectionIds,
|
||
// status: "ACTIVE",
|
||
// },
|
||
// media: mediaInputs,
|
||
// },
|
||
// });
|
||
// const createProdJson = await createProdRes.json();
|
||
// console.log("Create product response:", createProdJson);
|
||
|
||
// const prodErrs = createProdJson.data.productCreate.userErrors;
|
||
// if (prodErrs.length) {
|
||
// const taken = prodErrs.some((e) => /already in use/i.test(e.message));
|
||
// if (taken) {
|
||
// results.push({ skippedHandle: attrs.part_number, reason: "handle in use" });
|
||
// continue;
|
||
// }
|
||
// throw new Error(`ProductCreate errors: ${prodErrs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
|
||
// const product = createProdJson.data.productCreate.product;
|
||
// const variantNode = product.variants.nodes[0];
|
||
// const variantId = variantNode.id;
|
||
// const inventoryItemId = variantNode.inventoryItem.id;
|
||
|
||
// // Fetch the Online Store publication ID
|
||
// console.log("Fetching Online Store publication ID...");
|
||
// const publicationsRes = await admin.graphql(`
|
||
// query {
|
||
// publications(first: 10) {
|
||
// edges {
|
||
// node {
|
||
// id
|
||
// name
|
||
// }
|
||
// }
|
||
// }
|
||
// }
|
||
// `);
|
||
// const publicationsJson = await publicationsRes.json();
|
||
// console.log("Publications response:", publicationsJson);
|
||
|
||
// const onlineStorePublication = publicationsJson.data.publications.edges.find(pub => pub.node.name === 'Online Store');
|
||
// console.log("Online Store Publication:", onlineStorePublication);
|
||
|
||
// const onlineStorePublicationId = onlineStorePublication ? onlineStorePublication.node.id : null;
|
||
// if (onlineStorePublicationId) {
|
||
// console.log("Publishing product to Online Store...");
|
||
// // Publish the product to the Online Store
|
||
// const publishRes = await admin.graphql(`
|
||
// mutation($id: ID!, $publicationId: ID!) {
|
||
// publishablePublish(id: $id, input: { publicationId: $publicationId }) {
|
||
// publishable {
|
||
// ... on Product {
|
||
// id
|
||
// title
|
||
// status
|
||
// }
|
||
// }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, {
|
||
// variables: {
|
||
// id: product.id,
|
||
// publicationId: onlineStorePublicationId,
|
||
// },
|
||
// });
|
||
// const publishJson = await publishRes.json();
|
||
// console.log("Publish response:", publishJson);
|
||
|
||
// const publishErrs = publishJson.data.publishablePublish.userErrors;
|
||
// if (publishErrs.length) {
|
||
// throw new Error(`Publish errors: ${publishErrs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
// } else {
|
||
// throw new Error("Online Store publication not found.");
|
||
// }
|
||
|
||
// // Update inventory item (SKU, cost & weight)
|
||
// const costPerItem = parseFloat(attrs.purchase_cost) || 0;
|
||
// const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0;
|
||
|
||
// console.log("Updating inventory item...");
|
||
// const invRes = await admin.graphql(`
|
||
// mutation($id: ID!, $input: InventoryItemInput!) {
|
||
// inventoryItemUpdate(id: $id, input: $input) {
|
||
// inventoryItem {
|
||
// id
|
||
// sku
|
||
// measurement {
|
||
// weight { value unit }
|
||
// }
|
||
// }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, {
|
||
// variables: {
|
||
// id: inventoryItemId,
|
||
// input: {
|
||
// sku: attrs.part_number,
|
||
// cost: costPerItem,
|
||
// measurement: {
|
||
// weight: { value: weightValue, unit: "POUNDS" }
|
||
// },
|
||
// },
|
||
// },
|
||
// });
|
||
// const invJson = await invRes.json();
|
||
// console.log("Inventory update response:", invJson);
|
||
|
||
// const invErrs = invJson.data.inventoryItemUpdate.userErrors;
|
||
// if (invErrs.length) {
|
||
// throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`);
|
||
// }
|
||
// const inventoryItem = invJson.data.inventoryItemUpdate.inventoryItem;
|
||
|
||
|
||
|
||
// results.push({
|
||
// productId: product.id,
|
||
// variant: {
|
||
// id: variantId, // Use the variantId from variantNode
|
||
// price: variantNode.price, // Use price from variantNode
|
||
// compareAtPrice: variantNode.compareAtPrice, // Use compareAtPrice from variantNode
|
||
// sku: inventoryItem.sku, // SKU from the updated inventory item
|
||
// barcode: variantNode.barcode, // Barcode from the variant
|
||
// weight: inventoryItem.measurement.weight.value, // Weight from inventory item
|
||
// weightUnit: inventoryItem.measurement.weight.unit, // Weight unit from inventory item
|
||
// },
|
||
// collections: collectionTitles,
|
||
// tags,
|
||
// });
|
||
|
||
|
||
// }
|
||
|
||
// return json({ success: true, results });
|
||
// };
|
||
|
||
export const action = async ({ request }) => {
|
||
const { admin } = await authenticate.admin(request);
|
||
const formData = await request.formData();
|
||
const brandId = formData.get("brandId");
|
||
const rawCount = formData.get("productCount");
|
||
const productCount = parseInt(rawCount, 10) || 10;
|
||
|
||
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
|
||
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||
|
||
// Fetch items from Turn14 API
|
||
console.log("Fetching items from Turn14 API...");
|
||
const itemsRes = await fetch(
|
||
`https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`,
|
||
{
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
}
|
||
);
|
||
const itemsData = await itemsRes.json();
|
||
console.log("Items data fetched:", itemsData);
|
||
|
||
function slugify(str) {
|
||
return str
|
||
.toString()
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
|
||
const items = Array.isArray(itemsData) ? itemsData.slice(0, productCount) : [];
|
||
console.log(`Processing ${items.length} items...`);
|
||
const results = [];
|
||
|
||
for (const item of items) {
|
||
const attrs = item.attributes;
|
||
console.log("Processing item:", attrs);
|
||
|
||
// Build and normalize collection titles
|
||
const category = attrs.category;
|
||
const subcategory = attrs.subcategory || "";
|
||
const brand = attrs.brand;
|
||
const subcats = subcategory
|
||
.split(/[,\/]/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
const collectionTitles = Array.from(
|
||
new Set([category, ...subcats, brand].filter(Boolean))
|
||
);
|
||
console.log("Collection Titles:", collectionTitles);
|
||
|
||
// Find or create collections, collect their IDs
|
||
const collectionIds = [];
|
||
for (const title of collectionTitles) {
|
||
console.log(`Searching for collection with title: ${title}`);
|
||
const lookupRes = await admin.graphql(`
|
||
{
|
||
collections(first: 1, query: "title:\\"${title}\\" AND collection_type:manual") {
|
||
nodes { id }
|
||
}
|
||
}
|
||
`);
|
||
const lookupJson = await lookupRes.json();
|
||
console.log("Lookup response for collections:", lookupJson);
|
||
|
||
const existing = lookupJson.data?.collections?.nodes || [];
|
||
if (existing.length) {
|
||
console.log(`Found existing collection for title: ${title}`);
|
||
collectionIds.push(existing[0]?.id); // Use optional chaining
|
||
} else {
|
||
console.log(`Creating new collection for title: ${title}`);
|
||
const createColRes = await admin.graphql(`
|
||
mutation($input: CollectionInput!) {
|
||
collectionCreate(input: $input) {
|
||
collection { id }
|
||
userErrors { field message }
|
||
}
|
||
}`, { variables: { input: { title } } });
|
||
|
||
const createColJson = await createColRes.json();
|
||
console.log("Create collection response:", createColJson);
|
||
|
||
const errs = createColJson.data?.collectionCreate?.userErrors || [];
|
||
if (errs.length) {
|
||
throw new Error(`Could not create collection "${title}": ${errs.map(e => e.message).join(", ")}`);
|
||
}
|
||
collectionIds.push(createColJson.data?.collectionCreate?.collection?.id); // Optional chaining here too
|
||
}
|
||
}
|
||
|
||
// Build tags
|
||
const tags = [
|
||
attrs.category,
|
||
...subcats,
|
||
attrs.brand,
|
||
attrs.part_number,
|
||
attrs.mfr_part_number,
|
||
attrs.price_group,
|
||
attrs.units_per_sku && `${attrs.units_per_sku} per SKU`,
|
||
attrs.barcode
|
||
].filter(Boolean).map((t) => t.trim());
|
||
console.log("Tags:", tags);
|
||
|
||
// Prepare media inputs
|
||
const mediaInputs = (attrs.files || [])
|
||
.filter((f) => f.type === "Image" && f.url)
|
||
.map((file) => ({
|
||
originalSource: file.url,
|
||
mediaContentType: "IMAGE",
|
||
alt: `${attrs.product_name} — ${file.media_content}`,
|
||
}));
|
||
console.log("Media inputs:", mediaInputs);
|
||
|
||
// Pick the longest "Market Description" or fallback to part_description
|
||
const marketDescs = (attrs.descriptions || [])
|
||
.filter((d) => d.type === "Market Description")
|
||
.map((d) => d.description);
|
||
const descriptionHtml = marketDescs.length
|
||
? marketDescs.reduce((a, b) => (b.length > a.length ? b : a))
|
||
: attrs.part_description;
|
||
console.log("Description HTML:", descriptionHtml);
|
||
|
||
// Create product + attach to collections + add media
|
||
// const createProdRes = await admin.graphql(`
|
||
// mutation($prod: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||
// productCreate(product: $prod, media: $media) {
|
||
// product {
|
||
// id
|
||
// variants(first: 1) {
|
||
// nodes { id inventoryItem { id } price compareAtPrice barcode }
|
||
// }
|
||
// }
|
||
// userErrors { field message }
|
||
// }
|
||
// }
|
||
// `, {
|
||
// variables: {
|
||
// prod: {
|
||
// title: attrs.product_name,
|
||
// descriptionHtml: descriptionHtml,
|
||
// vendor: attrs.brand,
|
||
// productType: attrs.category,
|
||
// handle: slugify(attrs.part_number || attrs.product_name),
|
||
// tags,
|
||
// collectionsToJoin: collectionIds,
|
||
// status: "ACTIVE",
|
||
// },
|
||
// media: mediaInputs,
|
||
// },
|
||
// });
|
||
|
||
const createProdRes = await admin.graphql(`
|
||
mutation($prod: ProductCreateInput!, $media: [CreateMediaInput!]) {
|
||
productCreate(product: $prod, media: $media) {
|
||
product {
|
||
id
|
||
variants(first: 1) {
|
||
nodes { id inventoryItem { id } price compareAtPrice barcode }
|
||
}
|
||
}
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`, {
|
||
variables: {
|
||
prod: {
|
||
title: attrs.product_name,
|
||
descriptionHtml: descriptionHtml,
|
||
vendor: attrs.brand,
|
||
productType: attrs.category,
|
||
handle: slugify(item.id+"-"+attrs.mfr_part_number || attrs.product_name),
|
||
tags,
|
||
collectionsToJoin: collectionIds,
|
||
status: "ACTIVE",
|
||
},
|
||
media: mediaInputs,
|
||
},
|
||
});
|
||
|
||
const createProdJson = await createProdRes.json();
|
||
console.log("Create product response:", createProdJson);
|
||
|
||
const prodErrs = createProdJson.data?.productCreate?.userErrors || [];
|
||
if (prodErrs.length) {
|
||
const taken = prodErrs.some((e) => /already in use/i.test(e.message));
|
||
if (taken) {
|
||
results.push({ skippedHandle: attrs.part_number, reason: "handle in use" });
|
||
continue;
|
||
}
|
||
throw new Error(`ProductCreate errors: ${prodErrs.map(e => e.message).join(", ")}`);
|
||
}
|
||
|
||
const product = createProdJson.data.productCreate.product;
|
||
const variantNode = product.variants?.nodes?.[0];
|
||
if (!variantNode) {
|
||
console.error("Variant node is undefined for product:", product.id);
|
||
continue;
|
||
}
|
||
|
||
const variantId = variantNode.id;
|
||
const inventoryItemId = variantNode.inventoryItem?.id;
|
||
|
||
// Fetch the Online Store publication ID
|
||
console.log("Fetching Online Store publication ID...");
|
||
const publicationsRes = await admin.graphql(`
|
||
query {
|
||
publications(first: 10) {
|
||
edges {
|
||
node {
|
||
id
|
||
name
|
||
}
|
||
}
|
||
}
|
||
}
|
||
`);
|
||
const publicationsJson = await publicationsRes.json();
|
||
console.log("Publications response:", publicationsJson);
|
||
|
||
const onlineStorePublication = publicationsJson.data.publications.edges.find(pub => pub.node.name === 'Online Store');
|
||
const onlineStorePublicationId = onlineStorePublication ? onlineStorePublication.node.id : null;
|
||
if (onlineStorePublicationId) {
|
||
console.log("Publishing product to Online Store...");
|
||
const publishRes = await admin.graphql(`
|
||
mutation($id: ID!, $publicationId: ID!) {
|
||
publishablePublish(id: $id, input: { publicationId: $publicationId }) {
|
||
publishable {
|
||
... on Product {
|
||
id
|
||
title
|
||
status
|
||
}
|
||
}
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`, {
|
||
variables: {
|
||
id: product.id,
|
||
publicationId: onlineStorePublicationId,
|
||
},
|
||
});
|
||
const publishJson = await publishRes.json();
|
||
console.log("Publish response:", publishJson);
|
||
|
||
const publishErrs = publishJson.data.publishablePublish.userErrors;
|
||
if (publishErrs.length) {
|
||
throw new Error(`Publish errors: ${publishErrs.map(e => e.message).join(", ")}`);
|
||
}
|
||
} else {
|
||
throw new Error("Online Store publication not found.");
|
||
}
|
||
|
||
// Update inventory item (SKU, cost & weight)
|
||
const costPerItem = parseFloat(attrs.purchase_cost) || 0;
|
||
const weightValue = parseFloat(attrs.dimensions?.[0]?.weight) || 0;
|
||
|
||
console.log("Updating inventory item...");
|
||
const invRes = await admin.graphql(`
|
||
mutation($id: ID!, $input: InventoryItemInput!) {
|
||
inventoryItemUpdate(id: $id, input: $input) {
|
||
inventoryItem {
|
||
id
|
||
sku
|
||
measurement {
|
||
weight { value unit }
|
||
}
|
||
}
|
||
userErrors { field message }
|
||
}
|
||
}
|
||
`, {
|
||
variables: {
|
||
id: inventoryItemId,
|
||
input: {
|
||
sku: attrs.part_number,
|
||
cost: costPerItem,
|
||
measurement: {
|
||
weight: { value: weightValue, unit: "POUNDS" }
|
||
},
|
||
},
|
||
},
|
||
});
|
||
const invJson = await invRes.json();
|
||
console.log("Inventory update response:", invJson);
|
||
|
||
const invErrs = invJson.data.inventoryItemUpdate.userErrors;
|
||
if (invErrs.length) {
|
||
throw new Error(`Inventory update errors: ${invErrs.map(e => e.message).join(", ")}`);
|
||
}
|
||
const inventoryItem = invJson.data.inventoryItemUpdate.inventoryItem;
|
||
|
||
// Collect results
|
||
results.push({
|
||
productId: product.id,
|
||
variant: {
|
||
id: variantId,
|
||
price: variantNode.price,
|
||
compareAtPrice: variantNode.compareAtPrice,
|
||
sku: inventoryItem.sku,
|
||
barcode: variantNode.barcode,
|
||
weight: inventoryItem.measurement.weight.value,
|
||
weightUnit: inventoryItem.measurement.weight.unit,
|
||
},
|
||
collections: collectionTitles,
|
||
tags,
|
||
});
|
||
}
|
||
|
||
return json({ success: true, results });
|
||
};
|
||
|
||
|
||
export default function ManageBrandProducts() {
|
||
const actionData = useActionData();
|
||
const { brands, accessToken } = useLoaderData();
|
||
const [expandedBrand, setExpandedBrand] = useState(null);
|
||
const [itemsMap, setItemsMap] = useState({});
|
||
const [loadingMap, setLoadingMap] = useState({});
|
||
const [productCount, setProductCount] = useState("10");
|
||
const [initialLoad, setInitialLoad] = useState(true);
|
||
|
||
const toggleAllBrands = async () => {
|
||
for (const brand of brands) {
|
||
await toggleBrandItems(brand.id);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (initialLoad && brands.length > 0) {
|
||
toggleAllBrands();
|
||
setInitialLoad(false);
|
||
}
|
||
}, [brands, initialLoad]);
|
||
|
||
// const toggleBrandItems = async (brandId) => {
|
||
// const isExpanded = expandedBrand === brandId;
|
||
// if (isExpanded) {
|
||
// setExpandedBrand(null);
|
||
// } else {
|
||
// setExpandedBrand(brandId);
|
||
// if (!itemsMap[brandId]) {
|
||
// setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
|
||
// try {
|
||
// const res = await fetch(`https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`, {
|
||
// headers: {
|
||
// Authorization: `Bearer ${accessToken}`,
|
||
// "Content-Type": "application/json",
|
||
// },
|
||
// });
|
||
// const data = await res.json();
|
||
// setItemsMap((prev) => ({ ...prev, [brandId]: data }));
|
||
// } catch (err) {
|
||
// console.error("Error fetching items:", err);
|
||
// }
|
||
// setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
|
||
// }
|
||
// }
|
||
// };
|
||
|
||
const toggleBrandItems = async (brandId) => {
|
||
const isExpanded = expandedBrand === brandId;
|
||
if (isExpanded) {
|
||
setExpandedBrand(null);
|
||
} else {
|
||
setExpandedBrand(brandId);
|
||
if (!itemsMap[brandId]) {
|
||
setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
|
||
try {
|
||
const res = await fetch(`https://turn14.data4autos.com/v1/items/brandallitems/${brandId}`, {
|
||
headers: {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
const data = await res.json();
|
||
// Ensure we have an array of valid items
|
||
const validItems = Array.isArray(data)
|
||
? data.filter(item => item && item.id && item.attributes)
|
||
: [];
|
||
setItemsMap((prev) => ({ ...prev, [brandId]: validItems }));
|
||
} catch (err) {
|
||
console.error("Error fetching items:", err);
|
||
setItemsMap((prev) => ({ ...prev, [brandId]: [] })); // Set empty array on error
|
||
}
|
||
setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
|
||
}
|
||
}
|
||
};
|
||
return (
|
||
<Page title="Data4Autos Turn14 Manage Brand Products">
|
||
<TitleBar title="Data4Autos Turn14 Integration" />
|
||
<Layout>
|
||
{brands.length === 0 ? (
|
||
<Layout.Section>
|
||
<Card sectioned>
|
||
<p>No brands selected yet.</p>
|
||
</Card>
|
||
</Layout.Section>
|
||
) : (
|
||
<Layout.Section>
|
||
<Card>
|
||
<IndexTable
|
||
resourceName={{ singular: "brand", plural: "brands" }}
|
||
itemCount={brands.length}
|
||
headings={[
|
||
{ title: "Brand ID" },
|
||
{ title: "Logo" },
|
||
{ title: "Action" },
|
||
{ title: "Products Count" },
|
||
]}
|
||
selectable={false}
|
||
>
|
||
{brands.map((brand, index) => (
|
||
<IndexTable.Row id={brand.id.toString()} key={brand.id} position={index}>
|
||
<IndexTable.Cell>{brand.id}</IndexTable.Cell>
|
||
<IndexTable.Cell>
|
||
<Thumbnail
|
||
source={
|
||
brand.logo ||
|
||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||
}
|
||
alt={brand.name}
|
||
size="small"
|
||
/>
|
||
</IndexTable.Cell>
|
||
<IndexTable.Cell>
|
||
<Button onClick={() => toggleBrandItems(brand.id)}>
|
||
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||
</Button>
|
||
</IndexTable.Cell>
|
||
<IndexTable.Cell>{itemsMap[brand.id]?.length || 0}</IndexTable.Cell>
|
||
</IndexTable.Row>
|
||
))}
|
||
</IndexTable>
|
||
</Card>
|
||
</Layout.Section>
|
||
)}
|
||
|
||
{brands.map(
|
||
(brand) =>
|
||
expandedBrand === brand.id && (
|
||
<Layout.Section fullWidth key={brand.id + "-expanded"}>
|
||
<Card sectioned>
|
||
{actionData?.success && (
|
||
<Banner title="✅ Product created!" status="success">
|
||
<p>
|
||
{actionData.results.map((r) => (
|
||
<span key={r.variant.id}>
|
||
Product {r.productId} – Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})<br />
|
||
</span>
|
||
))}
|
||
</p>
|
||
</Banner>
|
||
)}
|
||
</Card>
|
||
|
||
<Card title={`Items from ${brand.name}`} sectioned>
|
||
{loadingMap[brand.id] ? (
|
||
<Spinner accessibilityLabel="Loading items" size="small" />
|
||
) : (
|
||
<div style={{ paddingTop: "1rem" }}>
|
||
<Form method="post">
|
||
<input type="hidden" name="brandId" value={brand.id} />
|
||
<TextField
|
||
label="Number of products to add"
|
||
type="number"
|
||
name="productCount"
|
||
value={productCount}
|
||
onChange={setProductCount}
|
||
autoComplete="off"
|
||
/>
|
||
<Button submit primary style={{ marginTop: "1rem" }}>
|
||
Add First {productCount} Products to Store
|
||
</Button>
|
||
</Form>
|
||
{/* <p>Total Products Available: {(itemsMap[brand.id] || []).length}</p>
|
||
{(
|
||
itemsMap[brand.id] && itemsMap[brand.id].length > 0
|
||
? itemsMap[brand.id]
|
||
: []
|
||
).map((item) => (
|
||
<Card key={item?.id} title={item?.attributes.product_name} sectioned>
|
||
<Layout>
|
||
<Layout.Section oneThird>
|
||
<Thumbnail
|
||
source={
|
||
item?.attributes.thumbnail ||
|
||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||
}
|
||
alt={item?.attributes.product_name}
|
||
size="large"
|
||
/>
|
||
</Layout.Section>
|
||
<Layout.Section>
|
||
<TextContainer spacing="tight">
|
||
<p><strong>Part Number:</strong> {item?.attributes.part_number}</p>
|
||
<p><strong>Category:</strong> {item?.attributes.category} > {item?.attributes.subcategory}</p>
|
||
<p><strong>Price:</strong> ${item?.attributes.price}</p>
|
||
<p><strong>Description:</strong> {item?.attributes.part_description}</p>
|
||
</TextContainer>
|
||
</Layout.Section>
|
||
</Layout>
|
||
</Card>
|
||
))} */}
|
||
|
||
{(
|
||
itemsMap[brand.id] && itemsMap[brand.id].length > 0
|
||
? itemsMap[brand.id].filter(item => item && item.id) // Filter out null/undefined items
|
||
: []
|
||
).map((item) => (
|
||
<Card key={item.id} title={item?.attributes?.product_name || 'Untitled Product'} sectioned>
|
||
<Layout>
|
||
<Layout.Section oneThird>
|
||
<Thumbnail
|
||
source={
|
||
item?.attributes?.thumbnail ||
|
||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||
}
|
||
alt={item?.attributes?.product_name || 'Product image'}
|
||
size="large"
|
||
/>
|
||
</Layout.Section>
|
||
<Layout.Section>
|
||
<TextContainer spacing="tight">
|
||
<p><strong>Part Number:</strong> {item?.attributes?.part_number || 'N/A'}</p>
|
||
<p><strong>Category:</strong> {item?.attributes?.category || 'N/A'} > {item?.attributes?.subcategory || 'N/A'}</p>
|
||
<p><strong>Price:</strong> ${item?.attributes?.price || '0.00'}</p>
|
||
<p><strong>Description:</strong> {item?.attributes?.part_description || 'No description available'}</p>
|
||
</TextContainer>
|
||
</Layout.Section>
|
||
</Layout>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</Layout.Section>
|
||
)
|
||
)}
|
||
</Layout>
|
||
</Page>
|
||
);
|
||
} |