- Total Products Count : {(itemsMap[brand.id] || []).length}
+ {/*
Total Products Available: {(itemsMap[brand.id] || []).length}
{(
itemsMap[brand.id] && itemsMap[brand.id].length > 0
? itemsMap[brand.id]
@@ -214,6 +1071,37 @@ useEffect(() => {
Part Number: {item?.attributes.part_number}
Category: {item?.attributes.category} > {item?.attributes.subcategory}
+ Price: ${item?.attributes.price}
+ Description: {item?.attributes.part_description}
+
+
+
+
+ ))} */}
+
+ {(
+ itemsMap[brand.id] && itemsMap[brand.id].length > 0
+ ? itemsMap[brand.id].filter(item => item && item.id) // Filter out null/undefined items
+ : []
+ ).map((item) => (
+
+
+
+
+
+
+
+ Part Number: {item?.attributes?.part_number || 'N/A'}
+ Category: {item?.attributes?.category || 'N/A'} > {item?.attributes?.subcategory || 'N/A'}
+ Price: ${item?.attributes?.price || '0.00'}
+ Description: {item?.attributes?.part_description || 'No description available'}
@@ -228,4 +1116,4 @@ useEffect(() => {
);
-}
+}
\ No newline at end of file
diff --git a/app/routes/app.managebrand_040725.jsx b/app/routes/app.managebrand_040725.jsx
new file mode 100644
index 0000000..e6f826d
--- /dev/null
+++ b/app/routes/app.managebrand_040725.jsx
@@ -0,0 +1,515 @@
+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 (
+
+
+
+ {brands.length === 0 ? (
+
+
+ No brands selected yet.
+
+
+ ) : (
+
+
+
+ {brands.map((brand, index) => (
+
+ {brand.id}
+
+
+
+
+ toggleBrandItems(brand.id)}>
+ {expandedBrand === brand.id ? "Hide Products" : "Show Products"}
+
+
+ {itemsMap[brand.id]?.length || 0}
+
+ ))}
+
+
+
+ )}
+
+ {brands.map(
+ (brand) =>
+ expandedBrand === brand.id && (
+
+
+ {actionData?.success && (
+
+
+ {actionData.results.map((r) => (
+
+ Product {r.productId} – Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})
+
+ ))}
+
+
+ )}
+
+
+
+ {loadingMap[brand.id] ? (
+
+ ) : (
+
+
+ {/*
Total Products Available: {(itemsMap[brand.id] || []).length}
+ {(
+ itemsMap[brand.id] && itemsMap[brand.id].length > 0
+ ? itemsMap[brand.id]
+ : []
+ ).map((item) => (
+
+
+
+
+
+
+
+ Part Number: {item?.attributes.part_number}
+ Category: {item?.attributes.category} > {item?.attributes.subcategory}
+ Price: ${item?.attributes.price}
+ Description: {item?.attributes.part_description}
+
+
+
+
+ ))} */}
+
+ {(
+ itemsMap[brand.id] && itemsMap[brand.id].length > 0
+ ? itemsMap[brand.id].filter(item => item && item.id) // Filter out null/undefined items
+ : []
+ ).map((item) => (
+
+
+
+
+
+
+
+ Part Number: {item?.attributes?.part_number || 'N/A'}
+ Category: {item?.attributes?.category || 'N/A'} > {item?.attributes?.subcategory || 'N/A'}
+ Price: ${item?.attributes?.price || '0.00'}
+ Description: {item?.attributes?.part_description || 'No description available'}
+
+
+
+
+ ))}
+
+ )}
+
+
+ )
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/shopify.app.toml b/shopify.app.toml
index fbb8a86..4ccfa50 100644
--- a/shopify.app.toml
+++ b/shopify.app.toml
@@ -20,7 +20,7 @@ api_version = "2025-04"
uri = "/webhooks/app/uninstalled"
[access_scopes]
-scopes = "read_inventory,read_products,write_inventory,write_products"
+scopes = "read_inventory,read_products,write_inventory,write_products,read_publications,write_publications"
[auth]
-redirect_urls = ["https://shopify.data4autos.com/auth/callback", "https://shopify.data4autos.com/auth/shopify"] # Update this line as well
+redirect_urls = ["https://backend.dine360.ca/auth/callback"] # Update this line as well