314 lines
8.8 KiB
JavaScript
314 lines
8.8 KiB
JavaScript
// app/routes/add-full-product.jsx
|
||
|
||
import { json } from "@remix-run/node";
|
||
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||
import {
|
||
Page,
|
||
Layout,
|
||
Card,
|
||
TextField,
|
||
Select,
|
||
Button,
|
||
Banner,
|
||
InlineError,
|
||
Text,
|
||
} from "@shopify/polaris";
|
||
import { useState, useEffect } from "react";
|
||
import { authenticate } from "../shopify.server";
|
||
|
||
export const loader = async ({ request }) => {
|
||
const { admin } = await authenticate.admin(request);
|
||
const resp = await admin.graphql(`
|
||
{
|
||
collections(first: 10, query: "collection_type:manual") {
|
||
nodes { id title }
|
||
}
|
||
}
|
||
`);
|
||
const { data } = await resp.json();
|
||
return json({ collections: data.collections.nodes });
|
||
};
|
||
|
||
export const action = async ({ request }) => {
|
||
const form = await request.formData();
|
||
const {
|
||
title,
|
||
descriptionHtml,
|
||
vendor,
|
||
productType,
|
||
handle,
|
||
tags,
|
||
price,
|
||
sku,
|
||
costPerItem, // new
|
||
collectionId,
|
||
imageUrl,
|
||
} = Object.fromEntries(form);
|
||
|
||
const { admin } = await authenticate.admin(request);
|
||
|
||
// 1️⃣ Create product + attach image + 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: tags.split(",").map((t) => t.trim()),
|
||
collectionsToJoin: [collectionId],
|
||
status: "ACTIVE",
|
||
},
|
||
media: [
|
||
{
|
||
originalSource: imageUrl,
|
||
mediaContentType: "IMAGE",
|
||
alt: `${title} image`,
|
||
},
|
||
],
|
||
},
|
||
}
|
||
);
|
||
const createJson = await createRes.json();
|
||
if (createJson.data.productCreate.userErrors.length) {
|
||
return json(
|
||
{ errors: createJson.data.productCreate.userErrors.map((e) => e.message) },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
const newProduct = createJson.data.productCreate.product;
|
||
const variantNode = newProduct.variants.nodes[0];
|
||
const variantId = variantNode.id;
|
||
const inventoryItemId = variantNode.inventoryItem.id;
|
||
|
||
// 2️⃣ Set price via bulk update
|
||
const priceRes = await admin.graphql(
|
||
`#graphql
|
||
mutation UpdatePrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||
productVariants { id price }
|
||
userErrors { field message }
|
||
}
|
||
}`,
|
||
{
|
||
variables: {
|
||
productId: newProduct.id,
|
||
variants: [{ id: variantId, price: parseFloat(price) }],
|
||
},
|
||
}
|
||
);
|
||
const priceJson = await priceRes.json();
|
||
if (priceJson.data.productVariantsBulkUpdate.userErrors.length) {
|
||
return json(
|
||
{ errors: priceJson.data.productVariantsBulkUpdate.userErrors.map((e) => e.message) },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
const updatedVariant = priceJson.data.productVariantsBulkUpdate.productVariants[0];
|
||
|
||
// 3️⃣ Update inventory item: set SKU + cost per item
|
||
const skuCostRes = 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), // unit cost in shop’s default currency :contentReference[oaicite:1]{index=1}
|
||
},
|
||
},
|
||
}
|
||
);
|
||
const skuCostJson = await skuCostRes.json();
|
||
if (skuCostJson.data.inventoryItemUpdate.userErrors.length) {
|
||
return json(
|
||
{ errors: skuCostJson.data.inventoryItemUpdate.userErrors.map((e) => e.message) },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
const finalSKU = skuCostJson.data.inventoryItemUpdate.inventoryItem.sku;
|
||
|
||
return json({
|
||
success: true,
|
||
product: newProduct,
|
||
variant: {
|
||
id: updatedVariant.id,
|
||
price: updatedVariant.price,
|
||
sku: finalSKU,
|
||
},
|
||
});
|
||
};
|
||
|
||
export default function AddFullProductPage() {
|
||
const { collections } = useLoaderData();
|
||
const actionData = useActionData();
|
||
|
||
// Controlled inputs
|
||
const [title, setTitle] = useState("Dummy Test Product");
|
||
const [descriptionHtml, setDescriptionHtml] = useState(
|
||
"<p>This is a dummy product for testing.</p>"
|
||
);
|
||
const [vendor, setVendor] = useState("Test Vendor");
|
||
const [productType, setProductType] = useState("Test Type");
|
||
const [handle, setHandle] = useState("dummy-test-product");
|
||
const [tags, setTags] = useState("test, dummy, sample");
|
||
const [price, setPrice] = useState("9.99");
|
||
const [costPerItem, setCostPerItem] = useState("5.00"); // new default
|
||
const [sku, setSku] = useState("DUMMY-9");
|
||
const [imageUrl, setImageUrl] = useState(
|
||
"https://via.placeholder.com/300x300.png?text=Dummy+Image"
|
||
);
|
||
const [collectionId, setCollectionId] = useState(
|
||
collections[0]?.id || ""
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!collectionId && collections.length) {
|
||
setCollectionId(collections[0].id);
|
||
}
|
||
}, [collections, collectionId]);
|
||
|
||
return (
|
||
<Page title="Add Full Product (Price + Cost)">
|
||
<Layout>
|
||
<Layout.Section>
|
||
<Card sectioned>
|
||
{actionData?.success && (
|
||
<Banner title="✅ Product created!" status="success">
|
||
<p>
|
||
{actionData.product.id} – Variant {actionData.variant.id} @ $
|
||
{actionData.variant.price} (SKU: {actionData.variant.sku})
|
||
</p>
|
||
</Banner>
|
||
)}
|
||
{actionData?.errors && (
|
||
<Banner title="🚨 Errors" status="critical">
|
||
<ul>
|
||
{actionData.errors.map((msg, i) => (
|
||
<li key={i}>
|
||
<InlineError message={msg} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</Banner>
|
||
)}
|
||
|
||
<Form method="post">
|
||
<TextField
|
||
label="Title"
|
||
name="title"
|
||
value={title}
|
||
onChange={setTitle}
|
||
required
|
||
/>
|
||
<TextField
|
||
label="Description (HTML)"
|
||
name="descriptionHtml"
|
||
multiline={4}
|
||
value={descriptionHtml}
|
||
onChange={setDescriptionHtml}
|
||
required
|
||
/>
|
||
<TextField
|
||
label="Vendor"
|
||
name="vendor"
|
||
value={vendor}
|
||
onChange={setVendor}
|
||
required
|
||
/>
|
||
<TextField
|
||
label="Product Type"
|
||
name="productType"
|
||
value={productType}
|
||
onChange={setProductType}
|
||
required
|
||
/>
|
||
<TextField
|
||
label="Handle (URL suffix)"
|
||
name="handle"
|
||
value={handle}
|
||
onChange={setHandle}
|
||
required
|
||
/>
|
||
<TextField
|
||
label="Tags (comma-separated)"
|
||
name="tags"
|
||
value={tags}
|
||
onChange={setTags}
|
||
/>
|
||
<TextField
|
||
type="number"
|
||
step="0.01"
|
||
label="Price (USD)"
|
||
name="price"
|
||
value={price}
|
||
onChange={setPrice}
|
||
required
|
||
/>
|
||
<TextField
|
||
type="number"
|
||
step="0.01"
|
||
label="Cost per Item"
|
||
name="costPerItem"
|
||
value={costPerItem}
|
||
onChange={setCostPerItem}
|
||
required
|
||
/>
|
||
<TextField
|
||
label="SKU"
|
||
name="sku"
|
||
value={sku}
|
||
onChange={setSku}
|
||
required
|
||
/>
|
||
<TextField
|
||
label="Image URL"
|
||
name="imageUrl"
|
||
value={imageUrl}
|
||
onChange={setImageUrl}
|
||
/>
|
||
<Select
|
||
label="Add to Collection"
|
||
name="collectionId"
|
||
options={collections.map((c) => ({
|
||
label: c.title,
|
||
value: c.id,
|
||
}))}
|
||
value={collectionId}
|
||
onChange={setCollectionId}
|
||
required
|
||
/>
|
||
<Button submit primary style={{ marginTop: "1rem" }}>
|
||
Create Product
|
||
</Button>
|
||
</Form>
|
||
</Card>
|
||
</Layout.Section>
|
||
</Layout>
|
||
</Page>
|
||
);
|
||
}
|