460 lines
14 KiB
JavaScript
460 lines
14 KiB
JavaScript
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";
|
||
|
||
// 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;
|
||
const title = attrs.product_name;
|
||
const descriptionHtml = attrs.part_description;
|
||
const vendor = attrs.brand;
|
||
const productType = attrs.category;
|
||
const handle = slugify(attrs.part_number || title);
|
||
const tags = [attrs.category, attrs.subcategory, attrs.brand]
|
||
.filter(Boolean)
|
||
.map((t) => t.trim());
|
||
const price = attrs.price?.toString() || "1000";
|
||
const compare_price = attrs.compare_price?.toString() || "2000";
|
||
const costPerItem = attrs.purchase_cost?.toString() || "100";
|
||
const sku = attrs.part_number || '';
|
||
const collectionId = "gid://shopify/Collection/447659409624";
|
||
|
||
// 🔥 Build a CreateMediaInput[] from every file in attrs.files
|
||
const mediaInputs = (attrs.files || []).map((file) => ({
|
||
originalSource: file.url,
|
||
mediaContentType: "IMAGE",
|
||
alt: `${title} – ${file.media_content}`,
|
||
}));
|
||
|
||
// 1️⃣ Create product + images + join collection
|
||
const createRes = await admin.graphql(
|
||
`#graphql
|
||
mutation CreateFullProduct(
|
||
$product: ProductCreateInput!
|
||
$media: [CreateMediaInput!]
|
||
) {
|
||
productCreate(product: $product, media: $media) {
|
||
product {
|
||
id
|
||
variants(first: 1) {
|
||
nodes {
|
||
id
|
||
inventoryItem {
|
||
id
|
||
}
|
||
}
|
||
}
|
||
}
|
||
userErrors {
|
||
field
|
||
message
|
||
}
|
||
}
|
||
}`,
|
||
{
|
||
variables: {
|
||
product: {
|
||
title,
|
||
descriptionHtml,
|
||
vendor,
|
||
productType,
|
||
handle,
|
||
tags,
|
||
collectionsToJoin: [collectionId],
|
||
status: "ACTIVE",
|
||
published: true, // Ensures product is published to the Online Store
|
||
},
|
||
media: mediaInputs, // ← now includes all your images
|
||
},
|
||
}
|
||
);
|
||
const createJson = await createRes.json();
|
||
const createErrs = createJson.data.productCreate.userErrors;
|
||
if (createErrs.length) {
|
||
const handleTaken = createErrs.some((e) =>
|
||
/already in use/i.test(e.message)
|
||
);
|
||
if (handleTaken) {
|
||
results.push({ skippedHandle: handle, reason: "handle already in use" });
|
||
continue;
|
||
}
|
||
throw new Error(
|
||
`Create errors: ${createErrs.map((e) => e.message).join(", ")}`
|
||
);
|
||
}
|
||
|
||
const newProduct = createJson.data.productCreate.product;
|
||
const {
|
||
id: variantId,
|
||
inventoryItem: { id: inventoryItemId },
|
||
} = newProduct.variants.nodes[0];
|
||
|
||
// 2️⃣ Bulk-update variant price + compareAtPrice
|
||
const variantInputs = [
|
||
{
|
||
id: variantId,
|
||
price: parseFloat(price),
|
||
...(compare_price && { compareAtPrice: parseFloat(compare_price) }),
|
||
},
|
||
];
|
||
const priceRes = await admin.graphql(
|
||
`#graphql
|
||
mutation UpdatePrice(
|
||
$productId: ID!
|
||
$variants: [ProductVariantsBulkInput!]!
|
||
) {
|
||
productVariantsBulkUpdate(
|
||
productId: $productId
|
||
variants: $variants
|
||
) {
|
||
productVariants {
|
||
id
|
||
price
|
||
compareAtPrice
|
||
}
|
||
userErrors {
|
||
field
|
||
message
|
||
}
|
||
}
|
||
}`,
|
||
{
|
||
variables: {
|
||
productId: newProduct.id,
|
||
variants: variantInputs,
|
||
},
|
||
}
|
||
);
|
||
const priceJson = await priceRes.json();
|
||
const priceErrs = priceJson.data.productVariantsBulkUpdate.userErrors;
|
||
if (priceErrs.length) {
|
||
throw new Error(
|
||
`Price update errors: ${priceErrs.map((e) => e.message).join(", ")}`
|
||
);
|
||
}
|
||
const updatedVariant =
|
||
priceJson.data.productVariantsBulkUpdate.productVariants[0];
|
||
|
||
// 3️⃣ Update inventory item to set SKU & cost
|
||
const skuRes = await admin.graphql(
|
||
`#graphql
|
||
mutation SetSKUAndCost(
|
||
$id: ID!
|
||
$input: InventoryItemInput!
|
||
) {
|
||
inventoryItemUpdate(id: $id, input: $input) {
|
||
inventoryItem {
|
||
id
|
||
sku
|
||
}
|
||
userErrors {
|
||
field
|
||
message
|
||
}
|
||
}
|
||
}`,
|
||
{
|
||
variables: {
|
||
id: inventoryItemId,
|
||
input: {
|
||
sku,
|
||
cost: parseFloat(costPerItem),
|
||
},
|
||
},
|
||
}
|
||
);
|
||
const skuJson = await skuRes.json();
|
||
const skuErrs = skuJson.data.inventoryItemUpdate.userErrors;
|
||
if (skuErrs.length) {
|
||
throw new Error(
|
||
`SKU update errors: ${skuErrs.map((e) => e.message).join(", ")}`
|
||
);
|
||
}
|
||
const updatedItem = skuJson.data.inventoryItemUpdate.inventoryItem;
|
||
|
||
|
||
|
||
|
||
const publicationsRes = await admin.graphql(
|
||
`#graphql
|
||
query {
|
||
publications(first: 5) {
|
||
nodes {
|
||
id
|
||
name
|
||
}
|
||
}
|
||
}`
|
||
);
|
||
|
||
const publications = await publicationsRes.json();
|
||
const onlineStorePublication = publications?.data?.publications?.nodes.find(
|
||
(pub) => pub.name === "Online Store"
|
||
);
|
||
|
||
const publicationId = onlineStorePublication?.id;
|
||
console.log("1234567", publicationId)
|
||
if (publicationId && newProduct.id) {
|
||
const publishRes = await admin.graphql(
|
||
`#graphql
|
||
mutation PublishProduct($productId: ID!, $publicationId: ID!) {
|
||
publishablePublish(
|
||
id: $productId,
|
||
input: [{ publicationId: $publicationId }]
|
||
) {
|
||
publishable {
|
||
publishedOnPublication(publicationId: $publicationId)
|
||
}
|
||
userErrors {
|
||
field
|
||
message
|
||
}
|
||
}
|
||
}`,
|
||
{
|
||
variables: {
|
||
productId: newProduct.id, // Product ID from the productCreate mutation
|
||
publicationId: publicationId, // Online Store Publication ID
|
||
},
|
||
}
|
||
);
|
||
const publishJson = await publishRes.json();
|
||
const publishErrs = publishJson.data.publishablePublish.userErrors;
|
||
|
||
if (publishErrs.length) {
|
||
throw new Error(
|
||
`Publish errors: ${publishErrs.map((e) => e.message).join(", ")}`
|
||
);
|
||
}
|
||
|
||
}
|
||
|
||
results.push({
|
||
productId: newProduct.id,
|
||
variant: {
|
||
id: updatedVariant.id,
|
||
price: updatedVariant.price,
|
||
compareAtPrice: updatedVariant.compareAtPrice,
|
||
sku: updatedItem.sku,
|
||
},
|
||
});
|
||
}
|
||
|
||
|
||
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="Manage Brand Products">
|
||
<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>
|
||
);
|
||
}
|