updated the front end desing
This commit is contained in:
parent
6cb1d01b0c
commit
922890b3b3
BIN
app/assets/data4autos_logo.png
Normal file
BIN
app/assets/data4autos_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@ -1,16 +1,16 @@
|
|||||||
import { useEffect } from "react";
|
/* import { useEffect, useState, useCallback } from "react";
|
||||||
import { useFetcher } from "@remix-run/react";
|
import { useFetcher } from "@remix-run/react";
|
||||||
import {
|
import {
|
||||||
Page,
|
Page,
|
||||||
Layout,
|
Layout,
|
||||||
Text,
|
|
||||||
Card,
|
Card,
|
||||||
|
Tabs,
|
||||||
Button,
|
Button,
|
||||||
BlockStack,
|
BlockStack,
|
||||||
Box,
|
|
||||||
List,
|
|
||||||
Link,
|
|
||||||
InlineStack,
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Link,
|
||||||
} from "@shopify/polaris";
|
} from "@shopify/polaris";
|
||||||
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
|
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
@ -23,67 +23,8 @@ export const loader = async ({ request }) => {
|
|||||||
|
|
||||||
export const action = async ({ request }) => {
|
export const action = async ({ request }) => {
|
||||||
const { admin } = await authenticate.admin(request);
|
const { admin } = await authenticate.admin(request);
|
||||||
const color = ["Red", "Orange", "Yellow", "Green"][
|
|
||||||
Math.floor(Math.random() * 4)
|
|
||||||
];
|
|
||||||
const response = await admin.graphql(
|
|
||||||
`#graphql
|
|
||||||
mutation populateProduct($product: ProductCreateInput!) {
|
|
||||||
productCreate(product: $product) {
|
|
||||||
product {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
handle
|
|
||||||
status
|
|
||||||
variants(first: 10) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
price
|
|
||||||
barcode
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
variables: {
|
|
||||||
product: {
|
|
||||||
title: `${color} Snowboard`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const responseJson = await response.json();
|
|
||||||
const product = responseJson.data.productCreate.product;
|
|
||||||
const variantId = product.variants.edges[0].node.id;
|
|
||||||
const variantResponse = await admin.graphql(
|
|
||||||
`#graphql
|
|
||||||
mutation shopifyRemixTemplateUpdateVariant($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
|
||||||
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
|
||||||
productVariants {
|
|
||||||
id
|
|
||||||
price
|
|
||||||
barcode
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
variables: {
|
|
||||||
productId: product.id,
|
|
||||||
variants: [{ id: variantId, price: "100.00" }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const variantResponseJson = await variantResponse.json();
|
|
||||||
|
|
||||||
return {
|
return null;
|
||||||
product: responseJson.data.productCreate.product,
|
|
||||||
variant: variantResponseJson.data.productVariantsBulkUpdate.productVariants,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
@ -97,6 +38,33 @@ export default function Index() {
|
|||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function TabsInsideOfACardExample() {
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(selectedTabIndex) => setSelected(selectedTabIndex),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "settings", content: "⚙️ Settings" },
|
||||||
|
{ id: "brands", content: "🏷️ Brands" },
|
||||||
|
{ id: "manage", content: "📦 Manage Brands" },
|
||||||
|
{ id: "help", content: "🆘 Help" },
|
||||||
|
{ id: "login", content: "🔐 Login" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LegacyCard>
|
||||||
|
<Tabs tabs={tabs} selected={selected} onSelect={handleTabChange}>
|
||||||
|
<LegacyCard.Section title={tabs[selected].content}>
|
||||||
|
<p>Tab {selected} selected</p>
|
||||||
|
</LegacyCard.Section>
|
||||||
|
</Tabs>
|
||||||
|
</LegacyCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (productId) {
|
if (productId) {
|
||||||
shopify.toast.show("Product created");
|
shopify.toast.show("Product created");
|
||||||
@ -106,229 +74,535 @@ export default function Index() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<TitleBar title="Remix app template">
|
<TitleBar
|
||||||
<button variant="primary" onClick={generateProduct}>
|
title="Data4Autos Turn 14 Integration"
|
||||||
Generate a product
|
/>
|
||||||
</button>
|
|
||||||
</TitleBar>
|
|
||||||
|
|
||||||
|
|
||||||
<BlockStack gap="500">
|
<BlockStack gap="500">
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
<Card>
|
<Card>
|
||||||
<BlockStack gap="500">
|
<BlockStack gap="500">
|
||||||
<BlockStack gap="200">
|
<BlockStack gap="200">
|
||||||
<Link url="/app/app/settings" removeUnderline>
|
<InlineStack gap="300">
|
||||||
Go to Settings Page
|
<Link url="/app/settings" removeUnderline>
|
||||||
</Link>
|
<Button>Go to Settings Page</Button>
|
||||||
|
|
||||||
<Text as="h2" variant="headingMd">
|
|
||||||
Congrats on creating a new Shopify app 🎉
|
|
||||||
</Text>
|
|
||||||
<Text variant="bodyMd" as="p">
|
|
||||||
This embedded app template uses{" "}
|
|
||||||
<Link
|
|
||||||
url="https://shopify.dev/docs/apps/tools/app-bridge"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
App Bridge
|
|
||||||
</Link>{" "}
|
|
||||||
interface examples like an{" "}
|
|
||||||
<Link url="/app/additional" removeUnderline>
|
|
||||||
additional page in the app nav
|
|
||||||
</Link>
|
</Link>
|
||||||
, as well as an{" "}
|
</InlineStack>
|
||||||
<Link
|
<InlineStack gap="300">
|
||||||
url="https://shopify.dev/docs/api/admin-graphql"
|
<Link url="/app/brands" removeUnderline>
|
||||||
target="_blank"
|
<Button>Go to Brands Page</Button>
|
||||||
removeUnderline
|
</Link>
|
||||||
>
|
</InlineStack>
|
||||||
Admin GraphQL
|
<InlineStack gap="300">
|
||||||
</Link>{" "}
|
<Link url="/app/managebrand" removeUnderline>
|
||||||
mutation demo, to provide a starting point for app
|
<Button>Go to Brands Manage Page</Button>
|
||||||
development.
|
</Link>
|
||||||
</Text>
|
</InlineStack>
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
<BlockStack gap="200">
|
|
||||||
<Text as="h3" variant="headingMd">
|
|
||||||
Get started with products
|
|
||||||
</Text>
|
|
||||||
<Text as="p" variant="bodyMd">
|
|
||||||
Generate a product with GraphQL and get the JSON output for
|
|
||||||
that product. Learn more about the{" "}
|
|
||||||
<Link
|
|
||||||
url="https://shopify.dev/docs/api/admin-graphql/latest/mutations/productCreate"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
productCreate
|
|
||||||
</Link>{" "}
|
|
||||||
mutation in our API references.
|
|
||||||
</Text>
|
|
||||||
</BlockStack>
|
|
||||||
<InlineStack gap="300">
|
|
||||||
<Button loading={isLoading} onClick={generateProduct}>
|
|
||||||
Generate a product
|
|
||||||
</Button>
|
|
||||||
{fetcher.data?.product && (
|
|
||||||
<Button
|
|
||||||
url={`shopify:admin/products/${productId}`}
|
|
||||||
target="_blank"
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
View product
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</InlineStack>
|
|
||||||
{fetcher.data?.product && (
|
|
||||||
<>
|
|
||||||
<Text as="h3" variant="headingMd">
|
|
||||||
{" "}
|
|
||||||
productCreate mutation
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
padding="400"
|
|
||||||
background="bg-surface-active"
|
|
||||||
borderWidth="025"
|
|
||||||
borderRadius="200"
|
|
||||||
borderColor="border"
|
|
||||||
overflowX="scroll"
|
|
||||||
>
|
|
||||||
<pre style={{ margin: 0 }}>
|
|
||||||
<code>
|
|
||||||
{JSON.stringify(fetcher.data.product, null, 2)}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</Box>
|
|
||||||
<Text as="h3" variant="headingMd">
|
|
||||||
{" "}
|
|
||||||
productVariantsBulkUpdate mutation
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
padding="400"
|
|
||||||
background="bg-surface-active"
|
|
||||||
borderWidth="025"
|
|
||||||
borderRadius="200"
|
|
||||||
borderColor="border"
|
|
||||||
overflowX="scroll"
|
|
||||||
>
|
|
||||||
<pre style={{ margin: 0 }}>
|
|
||||||
<code>
|
|
||||||
{JSON.stringify(fetcher.data.variant, null, 2)}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</BlockStack>
|
</BlockStack>
|
||||||
</Card>
|
</Card>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
<Layout.Section variant="oneThird">
|
</Layout>
|
||||||
<BlockStack gap="500">
|
</BlockStack>
|
||||||
<Card>
|
|
||||||
<BlockStack gap="200">
|
|
||||||
<Text as="h2" variant="headingMd">
|
|
||||||
App template specs
|
|
||||||
</Text>
|
|
||||||
<BlockStack gap="200">
|
|
||||||
<InlineStack align="space-between">
|
|
||||||
<Text as="span" variant="bodyMd">
|
|
||||||
Framework
|
|
||||||
</Text>
|
|
||||||
<Link
|
|
||||||
url="https://remix.run"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
Remix
|
|
||||||
</Link>
|
|
||||||
</InlineStack>
|
|
||||||
<InlineStack align="space-between">
|
|
||||||
<Text as="span" variant="bodyMd">
|
|
||||||
Database
|
|
||||||
</Text>
|
|
||||||
<Link
|
|
||||||
url="https://www.prisma.io/"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
Prisma
|
|
||||||
</Link>
|
|
||||||
</InlineStack>
|
|
||||||
<InlineStack align="space-between">
|
|
||||||
<Text as="span" variant="bodyMd">
|
|
||||||
Interface
|
|
||||||
</Text>
|
|
||||||
<span>
|
|
||||||
<Link
|
|
||||||
url="https://polaris.shopify.com"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
Polaris
|
|
||||||
</Link>
|
|
||||||
{", "}
|
|
||||||
<Link
|
|
||||||
url="https://shopify.dev/docs/apps/tools/app-bridge"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
App Bridge
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</InlineStack>
|
|
||||||
<InlineStack align="space-between">
|
|
||||||
<Text as="span" variant="bodyMd">
|
|
||||||
API
|
|
||||||
</Text>
|
|
||||||
<Link
|
|
||||||
url="https://shopify.dev/docs/api/admin-graphql"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
GraphQL API
|
|
||||||
</Link>
|
|
||||||
</InlineStack>
|
|
||||||
</BlockStack>
|
|
||||||
</BlockStack>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<BlockStack gap="200">
|
|
||||||
<Text as="h2" variant="headingMd">
|
|
||||||
Next steps
|
|
||||||
</Text>
|
|
||||||
<List>
|
|
||||||
<List.Item>
|
|
||||||
Build an{" "}
|
|
||||||
<Link
|
|
||||||
url="https://shopify.dev/docs/apps/getting-started/build-app-example"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
{" "}
|
|
||||||
example app
|
|
||||||
</Link>{" "}
|
|
||||||
to get started
|
|
||||||
</List.Item>
|
|
||||||
<List.Item>
|
|
||||||
Explore Shopify’s API with{" "}
|
|
||||||
<Link
|
|
||||||
url="https://shopify.dev/docs/apps/tools/graphiql-admin-api"
|
|
||||||
target="_blank"
|
|
||||||
removeUnderline
|
|
||||||
>
|
|
||||||
GraphiQL
|
|
||||||
</Link>
|
|
||||||
</List.Item>
|
|
||||||
</List>
|
|
||||||
</BlockStack>
|
|
||||||
</Card>
|
|
||||||
</BlockStack>
|
|
||||||
</Layout.Section>
|
|
||||||
</Layout>
|
|
||||||
</BlockStack>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* //woking code
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Tabs,
|
||||||
|
Button,
|
||||||
|
BlockStack,
|
||||||
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Link,
|
||||||
|
LegacyCard,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const shopify = useAppBridge();
|
||||||
|
const isLoading =
|
||||||
|
["loading", "submitting"].includes(fetcher.state) &&
|
||||||
|
fetcher.formMethod === "POST";
|
||||||
|
const productId = fetcher.data?.product?.id?.replace(
|
||||||
|
"gid://shopify/Product/",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Temporarily disabling toast to avoid crash
|
||||||
|
// You can safely add App Bridge toast later
|
||||||
|
}, [productId, shopify]);
|
||||||
|
|
||||||
|
const generateProduct = () => fetcher.submit({}, { method: "POST" });
|
||||||
|
|
||||||
|
// Tabs logic
|
||||||
|
const [selectedTab, setSelectedTab] = useState(0);
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(selectedTabIndex) => setSelectedTab(selectedTabIndex),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: "settings-tab",
|
||||||
|
content: "⚙️ Settings",
|
||||||
|
panelID: "settings-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "brands-tab",
|
||||||
|
content: "🏷️ Brands",
|
||||||
|
panelID: "brands-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-tab",
|
||||||
|
content: "📦 Manage Brands",
|
||||||
|
panelID: "manage-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-tab",
|
||||||
|
content: "🆘 Help",
|
||||||
|
panelID: "help-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "login-tab",
|
||||||
|
content: "🔐 Login",
|
||||||
|
panelID: "login-content",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Data4Autos Turn 14 Integration" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<Tabs tabs={tabs} selected={selectedTab} onSelect={handleTabChange}>
|
||||||
|
<LegacyCard.Section title={tabs[selectedTab].content}>
|
||||||
|
{selectedTab === 0 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>Configure Turn14 integration settings.</Text>
|
||||||
|
<Link url="/app/settings">
|
||||||
|
<Button>Go to Settings Page</Button>
|
||||||
|
</Link>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
{selectedTab === 1 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>View available brands from Turn14.</Text>
|
||||||
|
<Link url="/app/brands">
|
||||||
|
<Button>Go to Brands Page</Button>
|
||||||
|
</Link>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
{selectedTab === 2 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>Manage your synced brand collections.</Text>
|
||||||
|
<Link url="/app/managebrand">
|
||||||
|
<Button>Go to Manage Brands</Button>
|
||||||
|
</Link>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
{selectedTab === 3 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>Help and documentation links.</Text>
|
||||||
|
<Button url="https://data4autos.com/help" external>
|
||||||
|
Open Help Docs
|
||||||
|
</Button>
|
||||||
|
<Button url="mailto:support@data4autos.com" tone="critical">
|
||||||
|
Contact Support
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
{selectedTab === 4 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>Login to manage Turn14 credentials.</Text>
|
||||||
|
<Link url="/app/login">
|
||||||
|
<Button>Go to Login</Button>
|
||||||
|
</Link>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
</LegacyCard.Section>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
} */
|
||||||
|
|
||||||
|
|
||||||
|
/* import { Page, Layout, Card, Text, BlockStack } from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text variant="headingLg" as="h2">
|
||||||
|
Welcome to your Turn14 integration dashboard!
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
Use the navigation in the left sidebar to manage settings, view brands,
|
||||||
|
sync collections, and more.
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
BlockStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
InlineStack,
|
||||||
|
Image,
|
||||||
|
Divider,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import data4autosLogo from "../assets/data4autos_logo.png"; // make sure this exists
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card padding="500">
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<InlineStack gap="200" align="center">
|
||||||
|
<Image
|
||||||
|
source={data4autosLogo}
|
||||||
|
alt="Data4Autos Logo"
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
<Text variant="headingLg" as="h1">
|
||||||
|
Welcome to your Turn14 Dashboard
|
||||||
|
</Text>
|
||||||
|
</InlineStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text variant="bodyMd">
|
||||||
|
🚀 <b>Data4Autos Turn14 Integration</b> gives you the power to sync
|
||||||
|
product brands, manage collections, and automate catalog setup directly from
|
||||||
|
Turn14 to your Shopify store.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="bodyMd">
|
||||||
|
🔧 Use the left sidebar to:
|
||||||
|
</Text>
|
||||||
|
<BlockStack gap="100">
|
||||||
|
<Text as="span">⚙️ Manage API settings</Text>
|
||||||
|
<Text as="span">🏷️ Browse and import available brands</Text>
|
||||||
|
<Text as="span">📦 Sync brand collections to Shopify</Text>
|
||||||
|
<Text as="span">🔐 Handle secure Turn14 login credentials</Text>
|
||||||
|
</BlockStack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<InlineStack align="center">
|
||||||
|
<Badge tone="success">Status: Connected</Badge>
|
||||||
|
<Text tone="subdued">Shopify x Turn14</Text>
|
||||||
|
</InlineStack>
|
||||||
|
|
||||||
|
<Text tone="subdued" alignment="center">
|
||||||
|
Need help? Contact us at{" "}
|
||||||
|
<a href="mailto:support@data4autos.com">support@data4autos.com</a>
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Tabs,
|
||||||
|
LegacyCard,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const shopify = useAppBridge();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const productId = fetcher.data?.product?.id?.replace(
|
||||||
|
"gid://shopify/Product/",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// You can add toast messages here if needed
|
||||||
|
}, [productId, shopify]);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: "settings-tab",
|
||||||
|
content: "⚙️ Settings",
|
||||||
|
panelID: "settings-content",
|
||||||
|
to: "/app/settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "brands-tab",
|
||||||
|
content: "🏷️ Brands",
|
||||||
|
panelID: "brands-content",
|
||||||
|
to: "/app/brands",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-tab",
|
||||||
|
content: "📦 Manage Brands",
|
||||||
|
panelID: "manage-content",
|
||||||
|
to: "/app/managebrand",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-tab",
|
||||||
|
content: "🆘 Help",
|
||||||
|
panelID: "help-content",
|
||||||
|
to: "/app/help",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "login-tab",
|
||||||
|
content: "🔐 Login",
|
||||||
|
panelID: "login-content",
|
||||||
|
to: "/app/login",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const [selectedTab, setSelectedTab] = useState(0);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(selectedTabIndex) => {
|
||||||
|
setSelectedTab(selectedTabIndex);
|
||||||
|
navigate(tabs[selectedTabIndex].to);
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Data4Autos Turn 14 Integration" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<Tabs tabs={tabs} selected={selectedTab} onSelect={handleTabChange}>
|
||||||
|
<LegacyCard.Section title={tabs[selectedTab].content}>
|
||||||
|
<p>Redirecting to {tabs[selectedTab].content}...</p>
|
||||||
|
</LegacyCard.Section>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Tabs,
|
||||||
|
Button,
|
||||||
|
BlockStack,
|
||||||
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Link,
|
||||||
|
LegacyCard,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import SettingsPage from "./app.settings"; // Adjust the path if needed
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const shopify = useAppBridge();
|
||||||
|
const isLoading =
|
||||||
|
["loading", "submitting"].includes(fetcher.state) &&
|
||||||
|
fetcher.formMethod === "POST";
|
||||||
|
const productId = fetcher.data?.product?.id?.replace(
|
||||||
|
"gid://shopify/Product/",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Toast placeholder (disabled)
|
||||||
|
}, [productId, shopify]);
|
||||||
|
|
||||||
|
const generateProduct = () => fetcher.submit({}, { method: "POST" });
|
||||||
|
|
||||||
|
const [selectedTab, setSelectedTab] = useState(0);
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(selectedTabIndex) => setSelectedTab(selectedTabIndex),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: "settings-tab",
|
||||||
|
content: "⚙️ Settings",
|
||||||
|
panelID: "settings-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "brands-tab",
|
||||||
|
content: "🏷️ Brands",
|
||||||
|
panelID: "brands-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manage-tab",
|
||||||
|
content: "📦 Manage Brands",
|
||||||
|
panelID: "manage-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "help-tab",
|
||||||
|
content: "🆘 Help",
|
||||||
|
panelID: "help-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "login-tab",
|
||||||
|
content: "🔐 Login",
|
||||||
|
panelID: "login-content",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Data4Autos Turn 14 Integration" />
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<Tabs tabs={tabs} selected={selectedTab} onSelect={handleTabChange}>
|
||||||
|
<LegacyCard.Section title={tabs[selectedTab].content}>
|
||||||
|
{selectedTab === 0 && (
|
||||||
|
<SettingsPage standalone={false} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTab === 1 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>View available brands from Turn14.</Text>
|
||||||
|
<Link url="/app/brands">
|
||||||
|
<Button>Go to Brands Page</Button>
|
||||||
|
</Link>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTab === 2 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>Manage your synced brand collections.</Text>
|
||||||
|
<Link url="/app/managebrand">
|
||||||
|
<Button>Go to Manage Brands</Button>
|
||||||
|
</Link>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTab === 3 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>Help and documentation links.</Text>
|
||||||
|
<Button url="https://data4autos.com/help" external>
|
||||||
|
Open Help Docs
|
||||||
|
</Button>
|
||||||
|
<Button url="mailto:support@data4autos.com" tone="critical">
|
||||||
|
Contact Support
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTab === 4 && (
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<Text>Login to manage Turn14 credentials.</Text>
|
||||||
|
<Link url="/app/login">
|
||||||
|
<Button>Go to Login</Button>
|
||||||
|
</Link>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
</LegacyCard.Section>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*/
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Frame,
|
Frame,
|
||||||
} from "@shopify/polaris";
|
} from "@shopify/polaris";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
@ -201,9 +202,11 @@ export default function BrandsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame>
|
<Frame>
|
||||||
<Page title="Brands List">
|
<Page title="Data4Autos Turn14 Brands List">
|
||||||
|
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem", flexWrap: "wrap" }}></div>
|
||||||
<TextField
|
<TextField
|
||||||
label="Search brands"
|
label="Search brands"
|
||||||
value={search}
|
value={search}
|
||||||
|
|||||||
95
app/routes/app.help.jsx
Normal file
95
app/routes/app.help.jsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Text,
|
||||||
|
BlockStack,
|
||||||
|
Link,
|
||||||
|
Button,
|
||||||
|
Collapsible,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HelpPage() {
|
||||||
|
const [openIndex, setOpenIndex] = useState(null);
|
||||||
|
|
||||||
|
const toggle = useCallback((index) => {
|
||||||
|
setOpenIndex((prev) => (prev === index ? null : index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const faqs = [
|
||||||
|
{
|
||||||
|
title: "📌 How do I connect my Turn14 account?",
|
||||||
|
content:
|
||||||
|
"Go to the Settings page, enter your Turn14 Client ID and Secret, then click 'Save & Connect'. A green badge will confirm successful connection.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "📦 Where can I import brands from?",
|
||||||
|
content:
|
||||||
|
"Use the 'Brands' tab in the left menu to view and import available brands from Turn14 into your Shopify store.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "🔄 How do I sync brand collections?",
|
||||||
|
content:
|
||||||
|
"In the 'Manage Brands' section, select the brands and hit 'Sync to Shopify'. A manual collection will be created or updated.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "🔐 Is my Turn14 API key secure?",
|
||||||
|
content:
|
||||||
|
"Yes. The credentials are stored using Shopify’s encrypted storage (metafields), ensuring they are safe and secure.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<TitleBar title="Help & Documentation" />
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card>
|
||||||
|
<BlockStack gap="400">
|
||||||
|
<Text variant="headingLg" as="h1">
|
||||||
|
Need Help? You’re in the Right Place!
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
This section covers frequently asked questions about the Data4Autos
|
||||||
|
Turn14 integration app.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{faqs.map((faq, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<Button
|
||||||
|
onClick={() => toggle(index)}
|
||||||
|
fullWidth
|
||||||
|
disclosure={openIndex === index}
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
{faq.title}
|
||||||
|
</Button>
|
||||||
|
<Collapsible open={openIndex === index}>
|
||||||
|
<Text as="p" tone="subdued" padding="200">
|
||||||
|
{faq.content}
|
||||||
|
</Text>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Text tone="subdued">
|
||||||
|
Still have questions? Email us at{" "}
|
||||||
|
<Link url="mailto:support@data4autos.com">
|
||||||
|
support@data4autos.com
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
|
/* import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
|
||||||
import { boundary } from "@shopify/shopify-app-remix/server";
|
import { boundary } from "@shopify/shopify-app-remix/server";
|
||||||
import { AppProvider } from "@shopify/shopify-app-remix/react";
|
import { AppProvider } from "@shopify/shopify-app-remix/react";
|
||||||
import { NavMenu } from "@shopify/app-bridge-react";
|
import { NavMenu } from "@shopify/app-bridge-react";
|
||||||
@ -34,6 +34,45 @@ export function ErrorBoundary() {
|
|||||||
return boundary.error(useRouteError());
|
return boundary.error(useRouteError());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const headers = (headersArgs) => {
|
||||||
|
return boundary.headers(headersArgs);
|
||||||
|
}; */
|
||||||
|
|
||||||
|
import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
|
||||||
|
import { boundary } from "@shopify/shopify-app-remix/server";
|
||||||
|
import { AppProvider } from "@shopify/shopify-app-remix/react";
|
||||||
|
import { NavMenu } from "@shopify/app-bridge-react";
|
||||||
|
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
await authenticate.admin(request);
|
||||||
|
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { apiKey } = useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProvider isEmbeddedApp apiKey={apiKey}>
|
||||||
|
<NavMenu>
|
||||||
|
<Link to="/app" rel="home">🏠 Home</Link>
|
||||||
|
<Link to="/app/settings">⚙️ Settings</Link>
|
||||||
|
<Link to="/app/brands">🏷️ Brands</Link>
|
||||||
|
<Link to="/app/managebrand">📦 Manage Brands</Link>
|
||||||
|
<Link to="/app/help">🆘 Help</Link>
|
||||||
|
</NavMenu>
|
||||||
|
<Outlet />
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary() {
|
||||||
|
return boundary.error(useRouteError());
|
||||||
|
}
|
||||||
|
|
||||||
export const headers = (headersArgs) => {
|
export const headers = (headersArgs) => {
|
||||||
return boundary.headers(headersArgs);
|
return boundary.headers(headersArgs);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
/* import React, { useState } from "react";
|
||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
import { useLoaderData } from "@remix-run/react";
|
import { useLoaderData, Form, useActionData } from "@remix-run/react";
|
||||||
import {
|
import {
|
||||||
Page,
|
Page,
|
||||||
Layout,
|
Layout,
|
||||||
@ -8,15 +9,18 @@ import {
|
|||||||
TextContainer,
|
TextContainer,
|
||||||
Spinner,
|
Spinner,
|
||||||
Button,
|
Button,
|
||||||
Text,
|
|
||||||
TextField,
|
TextField,
|
||||||
|
Banner,
|
||||||
|
InlineError,
|
||||||
} from "@shopify/polaris";
|
} from "@shopify/polaris";
|
||||||
import { useState } from "react";
|
|
||||||
import { authenticate } from "../shopify.server";
|
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 }) => {
|
export const loader = async ({ request }) => {
|
||||||
const { admin } = await authenticate.admin(request);
|
const { admin } = await authenticate.admin(request);
|
||||||
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
|
const { getTurn14AccessTokenFromMetafield } = await import(
|
||||||
|
"../utils/turn14Token.server"
|
||||||
|
);
|
||||||
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
const accessToken = await getTurn14AccessTokenFromMetafield(request);
|
||||||
|
|
||||||
const res = await admin.graphql(`
|
const res = await admin.graphql(`
|
||||||
@ -28,7 +32,6 @@ export const loader = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const rawValue = data?.data?.shop?.metafield?.value;
|
const rawValue = data?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
@ -42,13 +45,262 @@ export const loader = async ({ request }) => {
|
|||||||
return json({ brands, accessToken });
|
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() {
|
export default function ManageBrandProducts() {
|
||||||
|
const actionData = useActionData();
|
||||||
const { brands, accessToken } = useLoaderData();
|
const { brands, accessToken } = useLoaderData();
|
||||||
const [expandedBrand, setExpandedBrand] = useState(null);
|
const [expandedBrand, setExpandedBrand] = useState(null);
|
||||||
const [itemsMap, setItemsMap] = useState({});
|
const [itemsMap, setItemsMap] = useState({});
|
||||||
const [loadingMap, setLoadingMap] = useState({});
|
const [loadingMap, setLoadingMap] = useState({});
|
||||||
const [productCount, setProductCount] = useState("10");
|
const [productCount, setProductCount] = useState("10");
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
|
|
||||||
const toggleBrandItems = async (brandId) => {
|
const toggleBrandItems = async (brandId) => {
|
||||||
const isExpanded = expandedBrand === brandId;
|
const isExpanded = expandedBrand === brandId;
|
||||||
@ -75,134 +327,9 @@ export default function ManageBrandProducts() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProducts = async (brandId) => {
|
|
||||||
const count = parseInt(productCount || "10");
|
|
||||||
const items = (itemsMap[brandId] || []).slice(0, count);
|
|
||||||
if (!items.length) return alert("No products to add.");
|
|
||||||
setAdding(true);
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const attr = item.attributes;
|
|
||||||
|
|
||||||
// Step 1: Create Product (only allowed fields)
|
|
||||||
const productInput = {
|
|
||||||
title: attr.product_name,
|
|
||||||
descriptionHtml: `<p>${attr.part_description}</p>`,
|
|
||||||
vendor: attr.brand,
|
|
||||||
productType: attr.category,
|
|
||||||
tags: [attr.subcategory, attr.brand].filter(Boolean).join(", "),
|
|
||||||
};
|
|
||||||
|
|
||||||
const createProductRes = await fetch("/admin/api/2023-04/graphql.json", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Shopify-Access-Token": accessToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: `
|
|
||||||
mutation productCreate($input: ProductInput!) {
|
|
||||||
productCreate(input: $input) {
|
|
||||||
product {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
}
|
|
||||||
userErrors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
variables: { input: productInput },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createProductResult = await createProductRes.json();
|
|
||||||
const product = createProductResult?.data?.productCreate?.product;
|
|
||||||
const productErrors = createProductResult?.data?.productCreate?.userErrors;
|
|
||||||
|
|
||||||
if (productErrors?.length || !product?.id) {
|
|
||||||
console.error("❌ Product create error:", productErrors);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const productId = product.id;
|
|
||||||
|
|
||||||
// Step 2: Create Variant
|
|
||||||
const variantInput = {
|
|
||||||
productId,
|
|
||||||
sku: attr.part_number,
|
|
||||||
barcode: attr.barcode || undefined,
|
|
||||||
price: "0.00",
|
|
||||||
weight: attr.dimensions?.[0]?.weight || 0,
|
|
||||||
weightUnit: "KILOGRAMS",
|
|
||||||
inventoryManagement: "SHOPIFY",
|
|
||||||
};
|
|
||||||
|
|
||||||
await fetch("/admin/api/2023-04/graphql.json", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Shopify-Access-Token": accessToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: `
|
|
||||||
mutation productVariantCreate($input: ProductVariantInput!) {
|
|
||||||
productVariantCreate(input: $input) {
|
|
||||||
productVariant {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
userErrors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
variables: { input: variantInput },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Add Image
|
|
||||||
if (attr.thumbnail) {
|
|
||||||
await fetch("/admin/api/2023-04/graphql.json", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Shopify-Access-Token": accessToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: `
|
|
||||||
mutation productImageCreate($productId: ID!, $image: ImageInput!) {
|
|
||||||
productImageCreate(productId: $productId, image: $image) {
|
|
||||||
image {
|
|
||||||
id
|
|
||||||
src
|
|
||||||
}
|
|
||||||
userErrors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
variables: {
|
|
||||||
productId,
|
|
||||||
image: {
|
|
||||||
src: attr.thumbnail,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ Added:", attr.product_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
setAdding(false);
|
|
||||||
alert(`${items.length} products added.`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="Manage Brand Products">
|
<Page title="Data4Autos Turn14 Manage Brand Products">
|
||||||
|
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||||
<Layout>
|
<Layout>
|
||||||
{brands.length === 0 && (
|
{brands.length === 0 && (
|
||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
@ -213,46 +340,54 @@ export default function ManageBrandProducts() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{brands.map((brand) => (
|
{brands.map((brand) => (
|
||||||
<div key={brand.id}>
|
<React.Fragment key={brand.id}>
|
||||||
<Layout.Section oneThird>
|
<Layout.Section oneThird>
|
||||||
<Card title={brand.name} sectioned>
|
<Card title={brand.name} sectioned>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
source={brand.logo || "https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"}
|
source={
|
||||||
|
brand.logo ||
|
||||||
|
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
|
||||||
|
}
|
||||||
alt={brand.name}
|
alt={brand.name}
|
||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
<TextContainer spacing="tight">
|
<TextContainer spacing="tight">
|
||||||
<p><strong>Brand:</strong> {brand.name}</p>
|
|
||||||
<p><strong>ID:</strong> {brand.id}</p>
|
<p><strong>ID:</strong> {brand.id}</p>
|
||||||
</TextContainer>
|
</TextContainer>
|
||||||
<div style={{ marginTop: "1rem" }}>
|
<Button fullWidth onClick={() => toggleBrandItems(brand.id)}>
|
||||||
<Button onClick={() => toggleBrandItems(brand.id)} fullWidth>
|
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||||
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
|
|
||||||
{expandedBrand === brand.id && (
|
{expandedBrand === brand.id && (
|
||||||
<Layout.Section fullWidth>
|
<Layout.Section fullWidth>
|
||||||
<Card sectioned>
|
<Card sectioned>
|
||||||
<TextField
|
{actionData?.success && (
|
||||||
label="Number of products to add"
|
<Banner title="✅ Product created!" status="success">
|
||||||
type="number"
|
<p>
|
||||||
value={productCount}
|
{actionData.results.map((r) => (
|
||||||
onChange={setProductCount}
|
<span key={r.variant.id}>
|
||||||
autoComplete="off"
|
Product {r.productId} – Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})<br />
|
||||||
/>
|
</span>
|
||||||
<div style={{ marginTop: "1rem" }}>
|
))}
|
||||||
<Button
|
</p>
|
||||||
onClick={() => handleAddProducts(brand.id)}
|
</Banner>
|
||||||
loading={adding}
|
)}
|
||||||
disabled={adding}
|
<Form method="post">
|
||||||
primary
|
<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
|
Add First {productCount} Products to Store
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={`Items from ${brand.name}`} sectioned>
|
<Card title={`Items from ${brand.name}`} sectioned>
|
||||||
@ -260,7 +395,7 @@ export default function ManageBrandProducts() {
|
|||||||
<Spinner accessibilityLabel="Loading items" size="small" />
|
<Spinner accessibilityLabel="Loading items" size="small" />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ paddingTop: "1rem" }}>
|
<div style={{ paddingTop: "1rem" }}>
|
||||||
{(itemsMap[brand.id] || []).map(item => (
|
{(itemsMap[brand.id] || []).map((item) => (
|
||||||
<Card key={item.id} title={item.attributes.product_name} sectioned>
|
<Card key={item.id} title={item.attributes.product_name} sectioned>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Section oneThird>
|
<Layout.Section oneThird>
|
||||||
@ -276,9 +411,7 @@ export default function ManageBrandProducts() {
|
|||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
<TextContainer spacing="tight">
|
<TextContainer spacing="tight">
|
||||||
<p><strong>Part Number:</strong> {item.attributes.part_number}</p>
|
<p><strong>Part Number:</strong> {item.attributes.part_number}</p>
|
||||||
<p><strong>Brand:</strong> {item.attributes.brand}</p>
|
<p><strong>Category:</strong> {item.attributes.category} > {item.attributes.subcategory}</p>
|
||||||
<p><strong>Category:</strong> {item.attributes.category} > {item.attributes.subcategory}</p>
|
|
||||||
<p><strong>Dimensions:</strong> {item.attributes.dimensions?.[0]?.length} x {item.attributes.dimensions?.[0]?.width} x {item.attributes.dimensions?.[0]?.height} in</p>
|
|
||||||
</TextContainer>
|
</TextContainer>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
@ -289,9 +422,203 @@ export default function ManageBrandProducts() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Layout.Section>
|
</Layout.Section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</Layout>
|
</Layout>
|
||||||
</Page>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,429 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
// 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="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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
297
app/routes/app.managebrand_bak.jsx
Normal file
297
app/routes/app.managebrand_bak.jsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
Thumbnail,
|
||||||
|
TextContainer,
|
||||||
|
Spinner,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
TextField,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
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 { brands, accessToken } = useLoaderData();
|
||||||
|
const [expandedBrand, setExpandedBrand] = useState(null);
|
||||||
|
const [itemsMap, setItemsMap] = useState({});
|
||||||
|
const [loadingMap, setLoadingMap] = useState({});
|
||||||
|
const [productCount, setProductCount] = useState("10");
|
||||||
|
const [adding, setAdding] = useState(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/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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProducts = async (brandId) => {
|
||||||
|
const count = parseInt(productCount || "10");
|
||||||
|
const items = (itemsMap[brandId] || []).slice(0, count);
|
||||||
|
if (!items.length) return alert("No products to add.");
|
||||||
|
setAdding(true);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const attr = item.attributes;
|
||||||
|
|
||||||
|
// Step 1: Create Product (only allowed fields)
|
||||||
|
const productInput = {
|
||||||
|
title: attr.product_name,
|
||||||
|
descriptionHtml: `<p>${attr.part_description}</p>`,
|
||||||
|
vendor: attr.brand,
|
||||||
|
productType: attr.category,
|
||||||
|
tags: [attr.subcategory, attr.brand].filter(Boolean).join(", "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProductRes = await fetch("/admin/api/2023-04/graphql.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
mutation productCreate($input: ProductInput!) {
|
||||||
|
productCreate(input: $input) {
|
||||||
|
product {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: { input: productInput },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProductResult = await createProductRes.json();
|
||||||
|
const product = createProductResult?.data?.productCreate?.product;
|
||||||
|
const productErrors = createProductResult?.data?.productCreate?.userErrors;
|
||||||
|
|
||||||
|
if (productErrors?.length || !product?.id) {
|
||||||
|
console.error("❌ Product create error:", productErrors);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = product.id;
|
||||||
|
|
||||||
|
// Step 2: Create Variant
|
||||||
|
const variantInput = {
|
||||||
|
productId,
|
||||||
|
sku: attr.part_number,
|
||||||
|
barcode: attr.barcode || undefined,
|
||||||
|
price: "0.00",
|
||||||
|
weight: attr.dimensions?.[0]?.weight || 0,
|
||||||
|
weightUnit: "KILOGRAMS",
|
||||||
|
inventoryManagement: "SHOPIFY",
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch("/admin/api/2023-04/graphql.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
mutation productVariantCreate($input: ProductVariantInput!) {
|
||||||
|
productVariantCreate(input: $input) {
|
||||||
|
productVariant {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: { input: variantInput },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Add Image
|
||||||
|
if (attr.thumbnail) {
|
||||||
|
await fetch("/admin/api/2023-04/graphql.json", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
mutation productImageCreate($productId: ID!, $image: ImageInput!) {
|
||||||
|
productImageCreate(productId: $productId, image: $image) {
|
||||||
|
image {
|
||||||
|
id
|
||||||
|
src
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: {
|
||||||
|
productId,
|
||||||
|
image: {
|
||||||
|
src: attr.thumbnail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Added:", attr.product_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdding(false);
|
||||||
|
alert(`${items.length} products added.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<div 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>Brand:</strong> {brand.name}</p>
|
||||||
|
<p><strong>ID:</strong> {brand.id}</p>
|
||||||
|
</TextContainer>
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<Button onClick={() => toggleBrandItems(brand.id)} fullWidth>
|
||||||
|
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
|
||||||
|
{expandedBrand === brand.id && (
|
||||||
|
<Layout.Section fullWidth>
|
||||||
|
<Card sectioned>
|
||||||
|
<TextField
|
||||||
|
label="Number of products to add"
|
||||||
|
type="number"
|
||||||
|
value={productCount}
|
||||||
|
onChange={setProductCount}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAddProducts(brand.id)}
|
||||||
|
loading={adding}
|
||||||
|
disabled={adding}
|
||||||
|
primary
|
||||||
|
>
|
||||||
|
Add First {productCount} Products to Store
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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>Brand:</strong> {item.attributes.brand}</p>
|
||||||
|
<p><strong>Category:</strong> {item.attributes.category} > {item.attributes.subcategory}</p>
|
||||||
|
<p><strong>Dimensions:</strong> {item.attributes.dimensions?.[0]?.length} x {item.attributes.dimensions?.[0]?.width} x {item.attributes.dimensions?.[0]?.height} in</p>
|
||||||
|
</TextContainer>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Layout>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { json } from "@remix-run/node";
|
import { json } from "@remix-run/node";
|
||||||
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
TextContainer,
|
TextContainer,
|
||||||
InlineError,
|
InlineError,
|
||||||
} from "@shopify/polaris";
|
} from "@shopify/polaris";
|
||||||
|
import { TitleBar } from "@shopify/app-bridge-react";
|
||||||
import { authenticate } from "../shopify.server";
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
export const loader = async ({ request }) => {
|
export const loader = async ({ request }) => {
|
||||||
@ -136,7 +137,7 @@ export const action = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage({ standalone = true }) {
|
||||||
const loaderData = useLoaderData();
|
const loaderData = useLoaderData();
|
||||||
const actionData = useActionData();
|
const actionData = useActionData();
|
||||||
|
|
||||||
@ -148,7 +149,8 @@ export default function SettingsPage() {
|
|||||||
const displayToken = actionData?.accessToken || savedCreds.accessToken;
|
const displayToken = actionData?.accessToken || savedCreds.accessToken;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="Turn14 API Settings">
|
<Page title="Data4Autos Turn14 API Settings">
|
||||||
|
<TitleBar title="Data4Autos Turn14 Integration" />
|
||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Section>
|
<Layout.Section>
|
||||||
<Card sectioned>
|
<Card sectioned>
|
||||||
@ -206,3 +208,220 @@ export default function SettingsPage() {
|
|||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
import { json } from "@remix-run/node";
|
||||||
|
import { useLoaderData, useActionData, Form } from "@remix-run/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
Layout,
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
InlineError,
|
||||||
|
BlockStack,
|
||||||
|
Text,
|
||||||
|
} from "@shopify/polaris";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
|
||||||
|
export const loader = async ({ request }) => {
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
const gqlResponse = await admin.graphql(`
|
||||||
|
{
|
||||||
|
shop {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
metafield(namespace: "turn14", key: "credentials") {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const shopData = await gqlResponse.json();
|
||||||
|
const shopName = shopData?.data?.shop?.name || "Unknown Shop";
|
||||||
|
const metafieldRaw = shopData?.data?.shop?.metafield?.value;
|
||||||
|
|
||||||
|
let creds = {};
|
||||||
|
if (metafieldRaw) {
|
||||||
|
try {
|
||||||
|
creds = JSON.parse(metafieldRaw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse stored credentials:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ shopName, creds });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const clientId = formData.get("client_id") || "";
|
||||||
|
const clientSecret = formData.get("client_secret") || "";
|
||||||
|
|
||||||
|
const { admin } = await authenticate.admin(request);
|
||||||
|
|
||||||
|
const shopInfo = await admin.graphql(`{ shop { id } }`);
|
||||||
|
const shopId = (await shopInfo.json())?.data?.shop?.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch("https://turn14.data4autos.com/v1/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenRes.json();
|
||||||
|
|
||||||
|
if (!tokenRes.ok) {
|
||||||
|
return json({
|
||||||
|
success: false,
|
||||||
|
error: tokenData.error || "Failed to fetch access token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = tokenData.access_token;
|
||||||
|
const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
accessToken,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutation = `
|
||||||
|
mutation {
|
||||||
|
metafieldsSet(metafields: [
|
||||||
|
{
|
||||||
|
ownerId: "${shopId}"
|
||||||
|
namespace: "turn14"
|
||||||
|
key: "credentials"
|
||||||
|
type: "json"
|
||||||
|
value: "${JSON.stringify(credentials).replace(/"/g, '\\"')}"
|
||||||
|
}
|
||||||
|
]) {
|
||||||
|
metafields {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const saveRes = await admin.graphql(mutation);
|
||||||
|
const result = await saveRes.json();
|
||||||
|
|
||||||
|
if (result?.data?.metafieldsSet?.userErrors?.length) {
|
||||||
|
return json({
|
||||||
|
success: false,
|
||||||
|
error: result.data.metafieldsSet.userErrors[0].message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
accessToken,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Turn14 token fetch failed:", err);
|
||||||
|
return json({
|
||||||
|
success: false,
|
||||||
|
error: "Network or unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsPage({ standalone = true }) {
|
||||||
|
const loaderData = useLoaderData();
|
||||||
|
const actionData = useActionData();
|
||||||
|
|
||||||
|
const savedCreds = loaderData?.creds || {};
|
||||||
|
const shopName = loaderData?.shopName || "Shop";
|
||||||
|
|
||||||
|
const [clientId, setClientId] = useState(
|
||||||
|
actionData?.clientId || savedCreds.clientId || ""
|
||||||
|
);
|
||||||
|
const [clientSecret, setClientSecret] = useState(
|
||||||
|
actionData?.clientSecret || savedCreds.clientSecret || ""
|
||||||
|
);
|
||||||
|
const displayToken = actionData?.accessToken || savedCreds.accessToken;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Layout>
|
||||||
|
<Layout.Section>
|
||||||
|
<Card sectioned>
|
||||||
|
<BlockStack gap="300">
|
||||||
|
<Text variant="bodyMd"><strong>Connected Shop:</strong> {shopName}</Text>
|
||||||
|
|
||||||
|
<Form method="post">
|
||||||
|
<BlockStack gap="200">
|
||||||
|
<TextField
|
||||||
|
label="Turn14 Client ID"
|
||||||
|
name="client_id"
|
||||||
|
value={clientId}
|
||||||
|
onChange={setClientId}
|
||||||
|
autoComplete="off"
|
||||||
|
requiredIndicator
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Turn14 Client Secret"
|
||||||
|
name="client_secret"
|
||||||
|
value={clientSecret}
|
||||||
|
onChange={setClientSecret}
|
||||||
|
autoComplete="off"
|
||||||
|
requiredIndicator
|
||||||
|
/>
|
||||||
|
<Button submit primary>
|
||||||
|
Generate Access Token
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<InlineError
|
||||||
|
message={`❌ ${actionData.error}`}
|
||||||
|
fieldID="client_id"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayToken && (
|
||||||
|
<BlockStack gap="100">
|
||||||
|
<Text variant="bodySm" fontWeight="semibold" tone="success">
|
||||||
|
✅ Access token:
|
||||||
|
</Text>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
background: "#f4f4f4",
|
||||||
|
padding: "10px",
|
||||||
|
display: "block",
|
||||||
|
wordWrap: "break-word",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayToken}
|
||||||
|
</code>
|
||||||
|
</BlockStack>
|
||||||
|
)}
|
||||||
|
</BlockStack>
|
||||||
|
</Card>
|
||||||
|
</Layout.Section>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
|
||||||
|
return standalone ? <Page title="Turn14 API Settings">{content}</Page> : content;
|
||||||
|
}
|
||||||
|
*/
|
||||||
@ -7,6 +7,9 @@ import {
|
|||||||
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
|
||||||
import prisma from "./db.server";
|
import prisma from "./db.server";
|
||||||
|
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
const shopify = shopifyApp({
|
const shopify = shopifyApp({
|
||||||
apiKey: process.env.SHOPIFY_API_KEY,
|
apiKey: process.env.SHOPIFY_API_KEY,
|
||||||
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
|
||||||
|
|||||||
33
package-lock.json
generated
33
package-lock.json
generated
@ -18,6 +18,7 @@
|
|||||||
"@shopify/polaris": "^12.27.0",
|
"@shopify/polaris": "^12.27.0",
|
||||||
"@shopify/shopify-app-remix": "^3.7.0",
|
"@shopify/shopify-app-remix": "^3.7.0",
|
||||||
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
|
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
|
||||||
|
"dotenv": "^17.0.0",
|
||||||
"isbot": "^5.1.0",
|
"isbot": "^5.1.0",
|
||||||
"prisma": "^6.2.1",
|
"prisma": "^6.2.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -2034,6 +2035,19 @@
|
|||||||
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
"graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@graphql-tools/prisma-loader/node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@graphql-tools/relay-operation-optimizer": {
|
"node_modules/@graphql-tools/relay-operation-optimizer": {
|
||||||
"version": "7.0.19",
|
"version": "7.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.19.tgz",
|
||||||
@ -2958,6 +2972,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@remix-run/dev/node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@remix-run/dev/node_modules/prettier": {
|
"node_modules/@remix-run/dev/node_modules/prettier": {
|
||||||
"version": "2.8.8",
|
"version": "2.8.8",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
|
||||||
@ -6462,9 +6488,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.5.0",
|
"version": "17.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.0.tgz",
|
||||||
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
|
"integrity": "sha512-A0BJ5lrpJVSfnMMXjmeO0xUnoxqsBHWCoqqTnGwGYVdnctqXXUEhJOO7LxmgxJon9tEZFGpe0xPRX0h2v3AANQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
"@shopify/polaris": "^12.27.0",
|
"@shopify/polaris": "^12.27.0",
|
||||||
"@shopify/shopify-app-remix": "^3.7.0",
|
"@shopify/shopify-app-remix": "^3.7.0",
|
||||||
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
|
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
|
||||||
|
"dotenv": "^17.0.0",
|
||||||
"isbot": "^5.1.0",
|
"isbot": "^5.1.0",
|
||||||
"prisma": "^6.2.1",
|
"prisma": "^6.2.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@ -1,20 +1,14 @@
|
|||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note that some adapters may set a maximum length for the String type by default, please ensure your strings are long
|
|
||||||
// enough when changing adapters.
|
|
||||||
// See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
url = "file:dev.sqlite"
|
url = "file:dev.sqlite"
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
shop String
|
shop String
|
||||||
state String
|
state String
|
||||||
isOnline Boolean @default(false)
|
isOnline Boolean @default(false)
|
||||||
@ -32,12 +26,12 @@ model Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Turn14Credential {
|
model Turn14Credential {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
shop String @unique
|
shop String @unique
|
||||||
clientId String
|
clientId String
|
||||||
clientSecret String
|
clientSecret String
|
||||||
accessToken String
|
accessToken String
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
|
||||||
|
|
||||||
client_id = "b7534c980967bad619cfdb9d3f837cfa"
|
client_id = "b7534c980967bad619cfdb9d3f837cfa"
|
||||||
name = "turn14-test"
|
name = "turn14-test"
|
||||||
handle = "turn14-test-1"
|
handle = "turn14-test-1"
|
||||||
application_url = "https://manhattan-fifty-pays-detector.trycloudflare.com"
|
application_url = "https://shopify.data4autos.com" # Update this line
|
||||||
embedded = true
|
embedded = true
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
@ -22,11 +20,7 @@ api_version = "2025-04"
|
|||||||
uri = "/webhooks/app/uninstalled"
|
uri = "/webhooks/app/uninstalled"
|
||||||
|
|
||||||
[access_scopes]
|
[access_scopes]
|
||||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
|
||||||
scopes = "read_inventory,read_products,write_inventory,write_products"
|
scopes = "read_inventory,read_products,write_inventory,write_products"
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
redirect_urls = ["https://manhattan-fifty-pays-detector.trycloudflare.com/auth/callback", "https://manhattan-fifty-pays-detector.trycloudflare.com/auth/shopify/callback", "https://manhattan-fifty-pays-detector.trycloudflare.com/api/auth/callback"]
|
redirect_urls = ["https://shopify.data4autos.com/auth/callback", "https://shopify.data4autos.com/auth/shopify"] # Update this line as well
|
||||||
|
|
||||||
[pos]
|
|
||||||
embedded = false
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user