Your commit message here
This commit is contained in:
parent
922890b3b3
commit
c58cc7c616
273
app/routes/app.brands copy.jsx
Normal file
273
app/routes/app.brands copy.jsx
Normal file
@ -0,0 +1,273 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData, useFetcher } from "@remix-run/react";
|
||||
import {
|
||||
Page,
|
||||
Layout,
|
||||
Card,
|
||||
TextField,
|
||||
Checkbox,
|
||||
Button,
|
||||
Thumbnail,
|
||||
Spinner,
|
||||
Toast,
|
||||
Frame,
|
||||
} from "@shopify/polaris";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TitleBar } from "@shopify/app-bridge-react";
|
||||
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
||||
import { authenticate } from "../shopify.server";
|
||||
|
||||
export const loader = async ({ request }) => {
|
||||
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||
const { admin } = await authenticate.admin(request);
|
||||
|
||||
// Get brands
|
||||
const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const brandJson = await brandRes.json();
|
||||
if (!brandRes.ok) {
|
||||
return json({ error: brandJson.error || "Failed to fetch brands" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get collections
|
||||
const gqlRaw = await admin.graphql(`
|
||||
{
|
||||
collections(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
handle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const gql = await gqlRaw.json();
|
||||
const collections = gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||||
|
||||
return json({
|
||||
brands: brandJson.data,
|
||||
collections,
|
||||
});
|
||||
};
|
||||
|
||||
export const action = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]");
|
||||
|
||||
const { admin } = await authenticate.admin(request);
|
||||
|
||||
// Get current collections
|
||||
const gqlRaw = await admin.graphql(`
|
||||
{
|
||||
collections(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const gql = await gqlRaw.json();
|
||||
const existingCollections = gql?.data?.collections?.edges?.map((e) => e.node) || [];
|
||||
|
||||
const selectedTitles = selectedBrands.map((b) => b.name.toLowerCase());
|
||||
const logoMap = Object.fromEntries(selectedBrands.map(b => [b.name.toLowerCase(), b.logo]));
|
||||
|
||||
// Delete unselected
|
||||
for (const col of existingCollections) {
|
||||
if (!selectedTitles.includes(col.title.toLowerCase())) {
|
||||
await admin.graphql(`
|
||||
mutation {
|
||||
collectionDelete(input: { id: "${col.id}" }) {
|
||||
deletedCollectionId
|
||||
userErrors { message }
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create new
|
||||
for (const brand of selectedBrands) {
|
||||
const exists = existingCollections.find(
|
||||
(c) => c.title.toLowerCase() === brand.name.toLowerCase()
|
||||
);
|
||||
if (!exists) {
|
||||
const escapedName = brand.name.replace(/"/g, '\\"');
|
||||
const logo = brand.logo || "";
|
||||
|
||||
await admin.graphql(`
|
||||
mutation {
|
||||
collectionCreate(input: {
|
||||
title: "${escapedName}",
|
||||
descriptionHtml: "Products from brand ${escapedName}",
|
||||
image: {
|
||||
altText: "${escapedName} Logo",
|
||||
src: "${logo}"
|
||||
}
|
||||
}) {
|
||||
collection { id }
|
||||
userErrors { message }
|
||||
}
|
||||
}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
const shopDataRaw = await admin.graphql(`
|
||||
{
|
||||
shop {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
const shopRes = await admin.graphql(`{ shop { id } }`);
|
||||
const shopJson = await shopRes.json();
|
||||
const shopId = shopJson?.data?.shop?.id;
|
||||
|
||||
await admin.graphql(`
|
||||
mutation {
|
||||
metafieldsSet(metafields: [{
|
||||
namespace: "turn14",
|
||||
key: "selected_brands",
|
||||
type: "json",
|
||||
ownerId: "${shopId}",
|
||||
value: ${JSON.stringify(JSON.stringify(selectedBrands))}
|
||||
}]) {
|
||||
metafields {
|
||||
id
|
||||
}
|
||||
userErrors {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
|
||||
return json({ success: true });
|
||||
|
||||
};
|
||||
|
||||
export default function BrandsPage() {
|
||||
const { brands, collections } = useLoaderData();
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
const [toastActive, setToastActive] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const collectionTitles = new Set(collections.map((c) => c.title.toLowerCase()));
|
||||
const defaultSelected = brands
|
||||
.filter((b) => collectionTitles.has(b.name.toLowerCase()))
|
||||
.map((b) => b.id);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(defaultSelected);
|
||||
const [filteredBrands, setFilteredBrands] = useState(brands);
|
||||
|
||||
useEffect(() => {
|
||||
const term = search.toLowerCase();
|
||||
setFilteredBrands(brands.filter((b) => b.name.toLowerCase().includes(term)));
|
||||
}, [search, brands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.success) {
|
||||
setToastActive(true);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const toastMarkup = toastActive ? (
|
||||
<Toast
|
||||
content="Collections updated successfully!"
|
||||
onDismiss={() => setToastActive(false)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const selectedBrands = brands.filter((b) => selectedIds.includes(b.id));
|
||||
|
||||
return (
|
||||
<Frame>
|
||||
<Page title="Data4Autos Turn14 Brands List">
|
||||
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||
<Layout>
|
||||
<Layout.Section>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", flexWrap: "wrap" }}></div>
|
||||
<TextField
|
||||
label="Search brands"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
autoComplete="off"
|
||||
placeholder="Type brand name..."
|
||||
/>
|
||||
</Layout.Section>
|
||||
|
||||
<Layout.Section>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
{filteredBrands.map((brand) => (
|
||||
<Card key={brand.id} sectioned>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Checkbox
|
||||
label=""
|
||||
checked={selectedIds.includes(brand.id)}
|
||||
onChange={() => toggleSelect(brand.id)}
|
||||
/>
|
||||
<Thumbnail
|
||||
source={
|
||||
brand.logo ||
|
||||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||
}
|
||||
alt={brand.name}
|
||||
size="small"
|
||||
/>
|
||||
<div>
|
||||
<strong>{brand.name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Layout.Section>
|
||||
|
||||
<Layout.Section>
|
||||
<fetcher.Form method="post">
|
||||
<input
|
||||
type="hidden"
|
||||
name="selectedBrands"
|
||||
value={JSON.stringify(selectedBrands)}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
submit
|
||||
disabled={selectedIds.length === 0 || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? <Spinner size="small" /> : "Save Collections"}
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
{toastMarkup}
|
||||
</Page>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@ -95,7 +95,6 @@ export const action = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create new
|
||||
for (const brand of selectedBrands) {
|
||||
const exists = existingCollections.find(
|
||||
@ -123,18 +122,18 @@ export const action = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const shopDataRaw = await admin.graphql(`
|
||||
const shopDataRaw = await admin.graphql(`
|
||||
{
|
||||
shop {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
const shopRes = await admin.graphql(`{ shop { id } }`);
|
||||
const shopJson = await shopRes.json();
|
||||
const shopId = shopJson?.data?.shop?.id;
|
||||
`);
|
||||
const shopRes = await admin.graphql(`{ shop { id } }`);
|
||||
const shopJson = await shopRes.json();
|
||||
const shopId = shopJson?.data?.shop?.id;
|
||||
|
||||
await admin.graphql(`
|
||||
await admin.graphql(`
|
||||
mutation {
|
||||
metafieldsSet(metafields: [{
|
||||
namespace: "turn14",
|
||||
@ -151,12 +150,9 @@ await admin.graphql(`
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
`);
|
||||
|
||||
return json({ success: true });
|
||||
|
||||
};
|
||||
|
||||
export default function BrandsPage() {
|
||||
@ -191,6 +187,22 @@ export default function BrandsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const filteredBrandIds = filteredBrands.map(b => b.id);
|
||||
const allFilteredSelected = filteredBrandIds.every(id => selectedIds.includes(id));
|
||||
|
||||
if (allFilteredSelected) {
|
||||
// Deselect all filtered brands
|
||||
setSelectedIds(prev => prev.filter(id => !filteredBrandIds.includes(id)));
|
||||
} else {
|
||||
// Select all filtered brands
|
||||
setSelectedIds(prev => {
|
||||
const combined = new Set([...prev, ...filteredBrandIds]);
|
||||
return Array.from(combined);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toastMarkup = toastActive ? (
|
||||
<Toast
|
||||
content="Collections updated successfully!"
|
||||
@ -199,6 +211,8 @@ export default function BrandsPage() {
|
||||
) : null;
|
||||
|
||||
const selectedBrands = brands.filter((b) => selectedIds.includes(b.id));
|
||||
const allFilteredSelected = filteredBrands.length > 0 &&
|
||||
filteredBrands.every(brand => selectedIds.includes(brand.id));
|
||||
|
||||
return (
|
||||
<Frame>
|
||||
@ -206,7 +220,7 @@ export default function BrandsPage() {
|
||||
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||
<Layout>
|
||||
<Layout.Section>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem", flexWrap: "wrap" }}></div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<TextField
|
||||
label="Search brands"
|
||||
value={search}
|
||||
@ -214,6 +228,14 @@ export default function BrandsPage() {
|
||||
autoComplete="off"
|
||||
placeholder="Type brand name..."
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
<Checkbox
|
||||
label="Select All"
|
||||
checked={allFilteredSelected}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Section>
|
||||
|
||||
<Layout.Section>
|
||||
|
||||
@ -1,435 +1,5 @@
|
||||
/* import React, { useState } from "react";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||||
import {
|
||||
Page,
|
||||
Layout,
|
||||
Card,
|
||||
Thumbnail,
|
||||
TextContainer,
|
||||
Spinner,
|
||||
Button,
|
||||
TextField,
|
||||
Banner,
|
||||
InlineError,
|
||||
} from "@shopify/polaris";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { TitleBar } from "@shopify/app-bridge-react";
|
||||
// Load selected brands and access token from Shopify metafield
|
||||
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 });
|
||||
};
|
||||
|
||||
// Handle adding products for a specific brand
|
||||
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 items1 = Array.isArray(itemsData) ? itemsData.slice(0, productCount) : [];
|
||||
const results = [];
|
||||
for (const item of items1) {
|
||||
const attrs = item.attributes;
|
||||
|
||||
// 0️⃣ 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))
|
||||
);
|
||||
|
||||
// 1️⃣ Find or create collections, collect their IDs
|
||||
const collectionIds = [];
|
||||
for (const title of collectionTitles) {
|
||||
// lookup
|
||||
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 {
|
||||
// create
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ 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());
|
||||
|
||||
// 3️⃣ 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}`,
|
||||
}));
|
||||
|
||||
|
||||
// 2️⃣ 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;
|
||||
|
||||
// 4️⃣ 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;
|
||||
|
||||
// 5️⃣ 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];
|
||||
|
||||
// 6️⃣ 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;
|
||||
|
||||
// 7️⃣ 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 });
|
||||
};
|
||||
|
||||
// Main React component for managing brand products
|
||||
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 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/brand/${brandId}?page=1`, {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{brands.map((brand) => (
|
||||
<React.Fragment key={brand.id}>
|
||||
<Layout.Section oneThird>
|
||||
<Card title={brand.name} sectioned>
|
||||
<Thumbnail
|
||||
source={
|
||||
brand.logo ||
|
||||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||
}
|
||||
alt={brand.name}
|
||||
size="large"
|
||||
/>
|
||||
<TextContainer spacing="tight">
|
||||
<p><strong>ID:</strong> {brand.id}</p>
|
||||
</TextContainer>
|
||||
<Button fullWidth onClick={() => toggleBrandItems(brand.id)}>
|
||||
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||
</Button>
|
||||
</Card>
|
||||
</Layout.Section>
|
||||
|
||||
{expandedBrand === brand.id && (
|
||||
<Layout.Section fullWidth>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card title={`Items from ${brand.name}`} sectioned>
|
||||
{loadingMap[brand.id] ? (
|
||||
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||
) : (
|
||||
<div style={{ paddingTop: "1rem" }}>
|
||||
{(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>
|
||||
</TextContainer>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Layout.Section>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Layout>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||||
import {
|
||||
@ -478,7 +48,29 @@ export default function ManageBrandProducts() {
|
||||
const [expandedBrand, setExpandedBrand] = useState(null);
|
||||
const [itemsMap, setItemsMap] = useState({});
|
||||
const [loadingMap, setLoadingMap] = useState({});
|
||||
const [productCount, setProductCount] = useState("10");
|
||||
const [productCount, setProductCount] = useState("0");
|
||||
|
||||
|
||||
|
||||
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
// Function to toggle all brands
|
||||
const toggleAllBrands = async () => {
|
||||
for (const brand of brands) {
|
||||
await toggleBrandItems(brand.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Run on initial load
|
||||
useEffect(() => {
|
||||
if (initialLoad && brands.length > 0) {
|
||||
toggleAllBrands();
|
||||
setInitialLoad(false);
|
||||
}
|
||||
}, [brands, initialLoad]);
|
||||
|
||||
|
||||
|
||||
const toggleBrandItems = async (brandId) => {
|
||||
const isExpanded = expandedBrand === brandId;
|
||||
@ -486,21 +78,26 @@ export default function ManageBrandProducts() {
|
||||
setExpandedBrand(null);
|
||||
} else {
|
||||
setExpandedBrand(brandId);
|
||||
|
||||
if (!itemsMap[brandId]) {
|
||||
setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
|
||||
try {
|
||||
const res = await fetch(`https://turn14.data4autos.com/v1/items/brand/${brandId}?page=1`, {
|
||||
// const res = await fetch(`https://turn14.data4autos.com/v1/items/brand/${brandId}?page=1`, {
|
||||
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();
|
||||
setProductCount(data.length)
|
||||
setItemsMap((prev) => ({ ...prev, [brandId]: data }));
|
||||
} catch (err) {
|
||||
console.error("Error fetching items:", err);
|
||||
}
|
||||
setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
|
||||
} else {
|
||||
setProductCount(itemsMap[brandId].length)
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -525,6 +122,7 @@ export default function ManageBrandProducts() {
|
||||
{ title: "Brand ID" },
|
||||
{ title: "Logo" },
|
||||
{ title: "Action" },
|
||||
{ title: "Products Count" },
|
||||
]}
|
||||
selectable={false}
|
||||
>
|
||||
@ -546,6 +144,7 @@ export default function ManageBrandProducts() {
|
||||
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||
</Button>
|
||||
</IndexTable.Cell>
|
||||
<IndexTable.Cell>{itemsMap[brand.id]?.length || 0}</IndexTable.Cell>
|
||||
</IndexTable.Row>
|
||||
))}
|
||||
</IndexTable>
|
||||
@ -569,6 +168,16 @@ export default function ManageBrandProducts() {
|
||||
</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
|
||||
@ -583,30 +192,28 @@ export default function ManageBrandProducts() {
|
||||
Add First {productCount} Products to Store
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title={`Items from ${brand.name}`} sectioned>
|
||||
{loadingMap[brand.id] ? (
|
||||
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||
) : (
|
||||
<div style={{ paddingTop: "1rem" }}>
|
||||
{(itemsMap[brand.id] || []).map((item) => (
|
||||
<Card key={item.id} title={item.attributes.product_name} sectioned>
|
||||
Total Products Count : {(itemsMap[brand.id] || []).length}
|
||||
{(
|
||||
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 ||
|
||||
item?.attributes.thumbnail ||
|
||||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||
}
|
||||
alt={item.attributes.product_name}
|
||||
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>Part Number:</strong> {item?.attributes.part_number}</p>
|
||||
<p><strong>Category:</strong> {item?.attributes.category} > {item?.attributes.subcategory}</p>
|
||||
</TextContainer>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
|
||||
@ -260,6 +260,8 @@ export default function ManageBrandProducts() {
|
||||
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||
) : (
|
||||
<div style={{ paddingTop: "1rem" }}>
|
||||
Total Products Count : {(itemsMap[brand.id] || []).length}
|
||||
|
||||
{(itemsMap[brand.id] || []).map(item => (
|
||||
<Card key={item.id} title={item.attributes.product_name} sectioned>
|
||||
<Layout>
|
||||
|
||||
624
app/routes/app.managebrand_bak_300625.jsx
Normal file
624
app/routes/app.managebrand_bak_300625.jsx
Normal file
@ -0,0 +1,624 @@
|
||||
/* import React, { useState } from "react";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||||
import {
|
||||
Page,
|
||||
Layout,
|
||||
Card,
|
||||
Thumbnail,
|
||||
TextContainer,
|
||||
Spinner,
|
||||
Button,
|
||||
TextField,
|
||||
Banner,
|
||||
InlineError,
|
||||
} from "@shopify/polaris";
|
||||
import { authenticate } from "../shopify.server";
|
||||
import { TitleBar } from "@shopify/app-bridge-react";
|
||||
// Load selected brands and access token from Shopify metafield
|
||||
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 });
|
||||
};
|
||||
|
||||
// Handle adding products for a specific brand
|
||||
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 items1 = Array.isArray(itemsData) ? itemsData.slice(0, productCount) : [];
|
||||
const results = [];
|
||||
for (const item of items1) {
|
||||
const attrs = item.attributes;
|
||||
|
||||
// 0️⃣ 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))
|
||||
);
|
||||
|
||||
// 1️⃣ Find or create collections, collect their IDs
|
||||
const collectionIds = [];
|
||||
for (const title of collectionTitles) {
|
||||
// lookup
|
||||
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 {
|
||||
// create
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ 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());
|
||||
|
||||
// 3️⃣ 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}`,
|
||||
}));
|
||||
|
||||
|
||||
// 2️⃣ 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;
|
||||
|
||||
// 4️⃣ 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;
|
||||
|
||||
// 5️⃣ 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];
|
||||
|
||||
// 6️⃣ 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;
|
||||
|
||||
// 7️⃣ 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 });
|
||||
};
|
||||
|
||||
// Main React component for managing brand products
|
||||
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 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/brand/${brandId}?page=1`, {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{brands.map((brand) => (
|
||||
<React.Fragment key={brand.id}>
|
||||
<Layout.Section oneThird>
|
||||
<Card title={brand.name} sectioned>
|
||||
<Thumbnail
|
||||
source={
|
||||
brand.logo ||
|
||||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||
}
|
||||
alt={brand.name}
|
||||
size="large"
|
||||
/>
|
||||
<TextContainer spacing="tight">
|
||||
<p><strong>ID:</strong> {brand.id}</p>
|
||||
</TextContainer>
|
||||
<Button fullWidth onClick={() => toggleBrandItems(brand.id)}>
|
||||
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||
</Button>
|
||||
</Card>
|
||||
</Layout.Section>
|
||||
|
||||
{expandedBrand === brand.id && (
|
||||
<Layout.Section fullWidth>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card title={`Items from ${brand.name}`} sectioned>
|
||||
{loadingMap[brand.id] ? (
|
||||
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||
) : (
|
||||
<div style={{ paddingTop: "1rem" }}>
|
||||
{(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>
|
||||
</TextContainer>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Layout.Section>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Layout>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
*/
|
||||
import React, { 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,
|
||||
} 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 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 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/brand/${brandId}?page=1`, {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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" },
|
||||
]}
|
||||
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.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>
|
||||
)}
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card title={`Items from ${brand.name}`} sectioned>
|
||||
{loadingMap[brand.id] ? (
|
||||
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||
) : (
|
||||
<div style={{ paddingTop: "1rem" }}>
|
||||
{(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>
|
||||
</TextContainer>
|
||||
</Layout.Section>
|
||||
</Layout>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Layout.Section>
|
||||
)
|
||||
)}
|
||||
</Layout>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@ -111,6 +111,8 @@ export const action = async ({ request }) => {
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
|
||||
const saveRes = await admin.graphql(mutation);
|
||||
const result = await saveRes.json();
|
||||
|
||||
@ -177,7 +179,7 @@ export default function SettingsPage({ standalone = true }) {
|
||||
/>
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<Button submit primary>
|
||||
Generate Access Token
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
@ -190,8 +192,8 @@ export default function SettingsPage({ standalone = true }) {
|
||||
|
||||
{displayToken && (
|
||||
<TextContainer spacing="tight" style={{ marginTop: "1rem" }}>
|
||||
<p style={{ color: "green" }}>✅ Access token:</p>
|
||||
<code style={{
|
||||
<p style={{ color: "green" }}>✅ Connection Successful</p>
|
||||
{/* <code style={{
|
||||
background: "#f4f4f4",
|
||||
padding: "10px",
|
||||
display: "block",
|
||||
@ -199,7 +201,7 @@ export default function SettingsPage({ standalone = true }) {
|
||||
wordWrap: "break-word"
|
||||
}}>
|
||||
{displayToken}
|
||||
</code>
|
||||
</code> */}
|
||||
</TextContainer>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
client_id = "b7534c980967bad619cfdb9d3f837cfa"
|
||||
name = "turn14-test"
|
||||
handle = "turn14-test-1"
|
||||
handle = "d4a-turn14"
|
||||
application_url = "https://shopify.data4autos.com" # Update this line
|
||||
embedded = true
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user