Data4Autos-Shopify-Frontend/app/routes/app.managebrand_040725.jsx
2025-07-12 17:20:28 +00:00

515 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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} &gt; {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'} &gt; {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>
);
}