updated the front end desing

This commit is contained in:
Metatron Host 2025-06-29 18:30:52 +00:00
parent 6cb1d01b0c
commit 922890b3b3
14 changed files with 1743 additions and 899 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -1,16 +1,16 @@
import { useEffect } from "react";
/* import { useEffect, useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import {
Page,
Layout,
Text,
Card,
Tabs,
Button,
BlockStack,
Box,
List,
Link,
InlineStack,
Text,
Badge,
Link,
} from "@shopify/polaris";
import { TitleBar, useAppBridge } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
@ -23,67 +23,8 @@ export const loader = async ({ request }) => {
export const action = async ({ 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 {
product: responseJson.data.productCreate.product,
variant: variantResponseJson.data.productVariantsBulkUpdate.productVariants,
};
return null;
};
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(() => {
if (productId) {
shopify.toast.show("Product created");
@ -106,12 +74,9 @@ export default function Index() {
return (
<Page>
<TitleBar title="Remix app template">
<button variant="primary" onClick={generateProduct}>
Generate a product
</button>
</TitleBar>
<TitleBar
title="Data4Autos Turn 14 Integration"
/>
<BlockStack gap="500">
<Layout>
@ -119,216 +84,525 @@ export default function Index() {
<Card>
<BlockStack gap="500">
<BlockStack gap="200">
<Link url="/app/app/settings" removeUnderline>
Go to Settings Page
</Link>
<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>
, as well as an{" "}
<Link
url="https://shopify.dev/docs/api/admin-graphql"
target="_blank"
removeUnderline
>
Admin GraphQL
</Link>{" "}
mutation demo, to provide a starting point for app
development.
</Text>
</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>
</Card>
</Layout.Section>
<Layout.Section variant="oneThird">
<BlockStack gap="500">
<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 url="/app/settings" removeUnderline>
<Button>Go to Settings Page</Button>
</Link>
</InlineStack>
<InlineStack align="space-between">
<Text as="span" variant="bodyMd">
Database
</Text>
<Link
url="https://www.prisma.io/"
target="_blank"
removeUnderline
>
Prisma
<InlineStack gap="300">
<Link url="/app/brands" removeUnderline>
<Button>Go to Brands Page</Button>
</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
<InlineStack gap="300">
<Link url="/app/managebrand" removeUnderline>
<Button>Go to Brands Manage Page</Button>
</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 Shopifys 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>
);
}
*/
/* //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>
);
}
*/

View File

@ -13,6 +13,7 @@ import {
Frame,
} from "@shopify/polaris";
import { useEffect, useState } from "react";
import { TitleBar } from "@shopify/app-bridge-react";
import { getTurn14AccessTokenFromMetafield } from "../utils/turn14Token.server";
import { authenticate } from "../shopify.server";
@ -201,9 +202,11 @@ export default function BrandsPage() {
return (
<Frame>
<Page title="Brands List">
<Page title="Data4Autos Turn14 Brands List">
<TitleBar title="Data4Autos Turn14 Integration" />
<Layout>
<Layout.Section>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", flexWrap: "wrap" }}></div>
<TextField
label="Search brands"
value={search}

95
app/routes/app.help.jsx Normal file
View 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 Shopifys 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? Youre 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>
);
}

View File

@ -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 { AppProvider } from "@shopify/shopify-app-remix/react";
import { NavMenu } from "@shopify/app-bridge-react";
@ -34,6 +34,45 @@ export function ErrorBoundary() {
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) => {
return boundary.headers(headersArgs);
};

View File

@ -1,5 +1,6 @@
/* import React, { useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useLoaderData, Form, useActionData } from "@remix-run/react";
import {
Page,
Layout,
@ -8,15 +9,18 @@ import {
TextContainer,
Spinner,
Button,
Text,
TextField,
Banner,
InlineError,
} from "@shopify/polaris";
import { useState } from "react";
import { authenticate } from "../shopify.server";
import { TitleBar } from "@shopify/app-bridge-react";
// Load selected brands and access token from Shopify metafield
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
const { getTurn14AccessTokenFromMetafield } = await import(
"../utils/turn14Token.server"
);
const accessToken = await getTurn14AccessTokenFromMetafield(request);
const res = await admin.graphql(`
@ -28,7 +32,6 @@ export const loader = async ({ request }) => {
}
}
`);
const data = await res.json();
const rawValue = data?.data?.shop?.metafield?.value;
@ -42,13 +45,262 @@ export const loader = async ({ request }) => {
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 [adding, setAdding] = useState(false);
const toggleBrandItems = async (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 (
<Page title="Manage Brand Products">
<Page title="Data4Autos Turn14 Manage Brand Products">
<TitleBar title="Data4Autos Turn14 Integration" />
<Layout>
{brands.length === 0 && (
<Layout.Section>
@ -213,46 +340,54 @@ export default function ManageBrandProducts() {
)}
{brands.map((brand) => (
<div key={brand.id}>
<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"}
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>
<Button fullWidth onClick={() => toggleBrandItems(brand.id)}>
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
</Button>
</div>
</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"
/>
<div style={{ marginTop: "1rem" }}>
<Button
onClick={() => handleAddProducts(brand.id)}
loading={adding}
disabled={adding}
primary
>
<Button submit primary style={{ marginTop: '1rem' }}>
Add First {productCount} Products to Store
</Button>
</div>
</Form>
</Card>
<Card title={`Items from ${brand.name}`} sectioned>
@ -260,7 +395,7 @@ export default function ManageBrandProducts() {
<Spinner accessibilityLabel="Loading items" size="small" />
) : (
<div style={{ paddingTop: "1rem" }}>
{(itemsMap[brand.id] || []).map(item => (
{(itemsMap[brand.id] || []).map((item) => (
<Card key={item.id} title={item.attributes.product_name} sectioned>
<Layout>
<Layout.Section oneThird>
@ -276,9 +411,7 @@ export default function ManageBrandProducts() {
<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>
<p><strong>Category:</strong> {item.attributes.category} &gt; {item.attributes.subcategory}</p>
</TextContainer>
</Layout.Section>
</Layout>
@ -289,9 +422,203 @@ export default function ManageBrandProducts() {
</Card>
</Layout.Section>
)}
</div>
</React.Fragment>
))}
</Layout>
</Page>
);
}
*/
import React, { useState } from "react";
import { json } from "@remix-run/node";
import { useLoaderData, Form, useActionData } from "@remix-run/react";
import {
Page,
Layout,
IndexTable,
Card,
Thumbnail,
TextContainer,
Spinner,
Button,
TextField,
Banner,
} from "@shopify/polaris";
import { authenticate } from "../shopify.server";
import { TitleBar } from "@shopify/app-bridge-react";
export const loader = async ({ request }) => {
const { admin } = await authenticate.admin(request);
const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server");
const accessToken = await getTurn14AccessTokenFromMetafield(request);
const res = await admin.graphql(`{
shop {
metafield(namespace: "turn14", key: "selected_brands") {
value
}
}
}`);
const data = await res.json();
const rawValue = data?.data?.shop?.metafield?.value;
let brands = [];
try {
brands = JSON.parse(rawValue);
} catch (err) {
console.error("❌ Failed to parse metafield value:", err);
}
return json({ brands, accessToken });
};
export default function ManageBrandProducts() {
const actionData = useActionData();
const { brands, accessToken } = useLoaderData();
const [expandedBrand, setExpandedBrand] = useState(null);
const [itemsMap, setItemsMap] = useState({});
const [loadingMap, setLoadingMap] = useState({});
const [productCount, setProductCount] = useState("10");
const toggleBrandItems = async (brandId) => {
const isExpanded = expandedBrand === brandId;
if (isExpanded) {
setExpandedBrand(null);
} else {
setExpandedBrand(brandId);
if (!itemsMap[brandId]) {
setLoadingMap((prev) => ({ ...prev, [brandId]: true }));
try {
const res = await fetch(`https://turn14.data4autos.com/v1/items/brand/${brandId}?page=1`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
const data = await res.json();
setItemsMap((prev) => ({ ...prev, [brandId]: data }));
} catch (err) {
console.error("Error fetching items:", err);
}
setLoadingMap((prev) => ({ ...prev, [brandId]: false }));
}
}
};
return (
<Page title="Data4Autos Turn14 Manage Brand Products">
<TitleBar title="Data4Autos Turn14 Integration" />
<Layout>
{brands.length === 0 ? (
<Layout.Section>
<Card sectioned>
<p>No brands selected yet.</p>
</Card>
</Layout.Section>
) : (
<Layout.Section>
<Card>
<IndexTable
resourceName={{ singular: "brand", plural: "brands" }}
itemCount={brands.length}
headings={[
{ title: "Brand ID" },
{ title: "Logo" },
{ title: "Action" },
]}
selectable={false}
>
{brands.map((brand, index) => (
<IndexTable.Row id={brand.id.toString()} key={brand.id} position={index}>
<IndexTable.Cell>{brand.id}</IndexTable.Cell>
<IndexTable.Cell>
<Thumbnail
source={
brand.logo ||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
}
alt={brand.name}
size="small"
/>
</IndexTable.Cell>
<IndexTable.Cell>
<Button onClick={() => toggleBrandItems(brand.id)}>
{expandedBrand === brand.id ? "Hide Products" : "Show Products"}
</Button>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
</Card>
</Layout.Section>
)}
{brands.map(
(brand) =>
expandedBrand === brand.id && (
<Layout.Section fullWidth key={brand.id + "-expanded"}>
<Card sectioned>
{actionData?.success && (
<Banner title="✅ Product created!" status="success">
<p>
{actionData.results.map((r) => (
<span key={r.variant.id}>
Product {r.productId} Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})<br />
</span>
))}
</p>
</Banner>
)}
<Form method="post">
<input type="hidden" name="brandId" value={brand.id} />
<TextField
label="Number of products to add"
type="number"
name="productCount"
value={productCount}
onChange={setProductCount}
autoComplete="off"
/>
<Button submit primary style={{ marginTop: "1rem" }}>
Add First {productCount} Products to Store
</Button>
</Form>
</Card>
<Card title={`Items from ${brand.name}`} sectioned>
{loadingMap[brand.id] ? (
<Spinner accessibilityLabel="Loading items" size="small" />
) : (
<div style={{ paddingTop: "1rem" }}>
{(itemsMap[brand.id] || []).map((item) => (
<Card key={item.id} title={item.attributes.product_name} sectioned>
<Layout>
<Layout.Section oneThird>
<Thumbnail
source={
item.attributes.thumbnail ||
"https://cdn.shopify.com/s/files/1/0757/9955/files/no-image_280x@2x.png"
}
alt={item.attributes.product_name}
size="large"
/>
</Layout.Section>
<Layout.Section>
<TextContainer spacing="tight">
<p><strong>Part Number:</strong> {item.attributes.part_number}</p>
<p><strong>Category:</strong> {item.attributes.category} &gt; {item.attributes.subcategory}</p>
</TextContainer>
</Layout.Section>
</Layout>
</Card>
))}
</div>
)}
</Card>
</Layout.Section>
)
)}
</Layout>
</Page>
);
}

View File

@ -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} &gt; {item.attributes.subcategory}</p>
</TextContainer>
</Layout.Section>
</Layout>
</Card>
))}
</div>
)}
</Card>
</Layout.Section>
)}
</React.Fragment>
))}
</Layout>
</Page>
);
}

View 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>
);
}

View File

@ -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 { useState } from "react";
import {
@ -10,6 +10,7 @@ import {
TextContainer,
InlineError,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
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 actionData = useActionData();
@ -148,7 +149,8 @@ export default function SettingsPage() {
const displayToken = actionData?.accessToken || savedCreds.accessToken;
return (
<Page title="Turn14 API Settings">
<Page title="Data4Autos Turn14 API Settings">
<TitleBar title="Data4Autos Turn14 Integration" />
<Layout>
<Layout.Section>
<Card sectioned>
@ -206,3 +208,220 @@ export default function SettingsPage() {
</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;
}
*/

View File

@ -7,6 +7,9 @@ import {
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";
import dotenv from 'dotenv';
dotenv.config();
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",

33
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@shopify/polaris": "^12.27.0",
"@shopify/shopify-app-remix": "^3.7.0",
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
"dotenv": "^17.0.0",
"isbot": "^5.1.0",
"prisma": "^6.2.1",
"react": "^18.2.0",
@ -2034,6 +2035,19 @@
"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": {
"version": "7.0.19",
"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": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
@ -6462,9 +6488,10 @@
}
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.0.0.tgz",
"integrity": "sha512-A0BJ5lrpJVSfnMMXjmeO0xUnoxqsBHWCoqqTnGwGYVdnctqXXUEhJOO7LxmgxJon9tEZFGpe0xPRX0h2v3AANQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},

View File

@ -32,6 +32,7 @@
"@shopify/polaris": "^12.27.0",
"@shopify/shopify-app-remix": "^3.7.0",
"@shopify/shopify-app-session-storage-prisma": "^6.0.0",
"dotenv": "^17.0.0",
"isbot": "^5.1.0",
"prisma": "^6.2.1",
"react": "^18.2.0",

View File

@ -1,13 +1,7 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
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 {
provider = "sqlite"
url = "file:dev.sqlite"

View File

@ -1,9 +1,7 @@
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = "b7534c980967bad619cfdb9d3f837cfa"
name = "turn14-test"
handle = "turn14-test-1"
application_url = "https://manhattan-fifty-pays-detector.trycloudflare.com"
application_url = "https://shopify.data4autos.com" # Update this line
embedded = true
[build]
@ -22,11 +20,7 @@ api_version = "2025-04"
uri = "/webhooks/app/uninstalled"
[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_inventory,read_products,write_inventory,write_products"
[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"]
[pos]
embedded = false
redirect_urls = ["https://shopify.data4autos.com/auth/callback", "https://shopify.data4autos.com/auth/shopify"] # Update this line as well