diff --git a/app/assets/data4autos_logo.png b/app/assets/data4autos_logo.png new file mode 100644 index 0000000..d9229e2 Binary files /dev/null and b/app/assets/data4autos_logo.png differ diff --git a/app/routes/app._index.jsx b/app/routes/app._index.jsx index 7b6d932..e4e1ba1 100644 --- a/app/routes/app._index.jsx +++ b/app/routes/app._index.jsx @@ -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 ( + + + +

Tab {selected} selected

+
+
+
+ ); +} + useEffect(() => { if (productId) { shopify.toast.show("Product created"); @@ -106,229 +74,535 @@ export default function Index() { return ( - - - + - - + - - Go to Settings Page - - - - Congrats on creating a new Shopify app 🎉 - - - This embedded app template uses{" "} - - App Bridge - {" "} - interface examples like an{" "} - - additional page in the app nav + + + - , as well as an{" "} - - Admin GraphQL - {" "} - mutation demo, to provide a starting point for app - development. - + + + + + + + + + + + - - - Get started with products - - - Generate a product with GraphQL and get the JSON output for - that product. Learn more about the{" "} - - productCreate - {" "} - mutation in our API references. - - - - - {fetcher.data?.product && ( - - )} - - {fetcher.data?.product && ( - <> - - {" "} - productCreate mutation - - -
-                        
-                          {JSON.stringify(fetcher.data.product, null, 2)}
-                        
-                      
-
- - {" "} - productVariantsBulkUpdate mutation - - -
-                        
-                          {JSON.stringify(fetcher.data.variant, null, 2)}
-                        
-                      
-
- - )}
- - - - - - App template specs - - - - - Framework - - - Remix - - - - - Database - - - Prisma - - - - - Interface - - - - Polaris - - {", "} - - App Bridge - - - - - - API - - - GraphQL API - - - - - - - - - Next steps - - - - Build an{" "} - - {" "} - example app - {" "} - to get started - - - Explore Shopify’s API with{" "} - - GraphiQL - - - - - - - -
-
+ +
); } + + */ + +/* //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 ( + + + + + + + + {selectedTab === 0 && ( + + Configure Turn14 integration settings. + + + + + )} + {selectedTab === 1 && ( + + View available brands from Turn14. + + + + + )} + {selectedTab === 2 && ( + + Manage your synced brand collections. + + + + + )} + {selectedTab === 3 && ( + + Help and documentation links. + + + + )} + {selectedTab === 4 && ( + + Login to manage Turn14 credentials. + + + + + )} + + + + + + + ); +} */ + + +/* 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 ( + + + + + + + + Welcome to your Turn14 integration dashboard! + + + Use the navigation in the left sidebar to manage settings, view brands, + sync collections, and more. + + + + + + + ); +} + */ + + +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 ( + + + + + + + + Data4Autos Logo + + Welcome to your Turn14 Dashboard + + + + + + + + 🚀 Data4Autos Turn14 Integration gives you the power to sync + product brands, manage collections, and automate catalog setup directly from + Turn14 to your Shopify store. + + + + 🔧 Use the left sidebar to: + + + ⚙️ Manage API settings + 🏷️ Browse and import available brands + 📦 Sync brand collections to Shopify + 🔐 Handle secure Turn14 login credentials + + + + + + Status: Connected + Shopify x Turn14 + + + + Need help? Contact us at{" "} + support@data4autos.com + + + + + + + + ); +} + + + + +/* 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 ( + + + + + + + +

Redirecting to {tabs[selectedTab].content}...

+
+
+
+
+
+
+ ); +} + + */ + + + + + +/* 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 ( + + + + + + + + + {selectedTab === 0 && ( + + )} + + {selectedTab === 1 && ( + + View available brands from Turn14. + + + + + )} + + {selectedTab === 2 && ( + + Manage your synced brand collections. + + + + + )} + + {selectedTab === 3 && ( + + Help and documentation links. + + + + )} + + {selectedTab === 4 && ( + + Login to manage Turn14 credentials. + + + + + )} + + + + + + + ); +} + */ \ No newline at end of file diff --git a/app/routes/app.brands.jsx b/app/routes/app.brands.jsx index 13e4199..85e25cc 100644 --- a/app/routes/app.brands.jsx +++ b/app/routes/app.brands.jsx @@ -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 ( - + + +
{ + 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 ( + + + + + + + + Need Help? You’re in the Right Place! + + + This section covers frequently asked questions about the Data4Autos + Turn14 integration app. + + + {faqs.map((faq, index) => ( +
+ + + + {faq.content} + + +
+ ))} + + + Still have questions? Email us at{" "} + + support@data4autos.com + + +
+
+
+
+
+ ); +} diff --git a/app/routes/app.jsx b/app/routes/app.jsx index 4bf8690..76a31bc 100644 --- a/app/routes/app.jsx +++ b/app/routes/app.jsx @@ -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 ( + + + 🏠 Home + ⚙️ Settings + 🏷️ Brands + 📦 Manage Brands + 🆘 Help + + + + ); +} + +export function ErrorBoundary() { + return boundary.error(useRouteError()); +} + export const headers = (headersArgs) => { return boundary.headers(headersArgs); }; diff --git a/app/routes/app.managebrand.jsx b/app/routes/app.managebrand.jsx index 1e6954a..5e29109 100644 --- a/app/routes/app.managebrand.jsx +++ b/app/routes/app.managebrand.jsx @@ -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: `

${attr.part_description}

`, - 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 ( - + + {brands.length === 0 && ( @@ -213,46 +340,54 @@ export default function ManageBrandProducts() { )} {brands.map((brand) => ( -
+ -

Brand: {brand.name}

ID: {brand.id}

-
- -
+
{expandedBrand === brand.id && ( - -
- -
+
@@ -260,7 +395,7 @@ export default function ManageBrandProducts() { ) : (
- {(itemsMap[brand.id] || []).map(item => ( + {(itemsMap[brand.id] || []).map((item) => ( @@ -276,9 +411,7 @@ export default function ManageBrandProducts() {

Part Number: {item.attributes.part_number}

-

Brand: {item.attributes.brand}

-

Category: {item.attributes.category} > {item.attributes.subcategory}

-

Dimensions: {item.attributes.dimensions?.[0]?.length} x {item.attributes.dimensions?.[0]?.width} x {item.attributes.dimensions?.[0]?.height} in

+

Category: {item.attributes.category} > {item.attributes.subcategory}

@@ -289,9 +422,203 @@ export default function ManageBrandProducts() {
)} -
+
))} ); } + */ +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 ( + + + + {brands.length === 0 ? ( + + +

No brands selected yet.

+
+
+ ) : ( + + + + {brands.map((brand, index) => ( + + {brand.id} + + + + + + + + ))} + + + + )} + + {brands.map( + (brand) => + expandedBrand === brand.id && ( + + + {actionData?.success && ( + +

+ {actionData.results.map((r) => ( + + Product {r.productId} – Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})
+
+ ))} +

+
+ )} +
+ + + + +
+ + + {loadingMap[brand.id] ? ( + + ) : ( +
+ {(itemsMap[brand.id] || []).map((item) => ( + + + + + + + +

Part Number: {item.attributes.part_number}

+

Category: {item.attributes.category} > {item.attributes.subcategory}

+
+
+
+
+ ))} +
+ )} +
+
+ ) + )} +
+
+ ); +} diff --git a/app/routes/app.managebrand1.jsx b/app/routes/app.managebrand1.jsx deleted file mode 100644 index 9a9c280..0000000 --- a/app/routes/app.managebrand1.jsx +++ /dev/null @@ -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 ( - - - {brands.length === 0 && ( - - -

No brands selected yet.

-
-
- )} - - {brands.map((brand) => ( - - - - - -

ID: {brand.id}

-
- -
-
- - {expandedBrand === brand.id && ( - - - {actionData?.success && ( - -

- {actionData.results.map((r) => ( - - Product {r.productId} – Variant {r.variant.id} @ ${r.variant.price} (SKU: {r.variant.sku})
-
- ))} -

-
- )} -
- - - - -
- - - {loadingMap[brand.id] ? ( - - ) : ( -
- {(itemsMap[brand.id] || []).map((item) => ( - - - - - - - -

Part Number: {item.attributes.part_number}

-

Category: {item.attributes.category} > {item.attributes.subcategory}

-
-
-
-
- ))} -
- )} -
-
- )} -
- ))} -
-
- ); -} diff --git a/app/routes/app.managebrand_bak.jsx b/app/routes/app.managebrand_bak.jsx new file mode 100644 index 0000000..1e6954a --- /dev/null +++ b/app/routes/app.managebrand_bak.jsx @@ -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: `

${attr.part_description}

`, + 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 ( + + + {brands.length === 0 && ( + + +

No brands selected yet.

+
+
+ )} + + {brands.map((brand) => ( +
+ + + + +

Brand: {brand.name}

+

ID: {brand.id}

+
+
+ +
+
+
+ + {expandedBrand === brand.id && ( + + + +
+ +
+
+ + + {loadingMap[brand.id] ? ( + + ) : ( +
+ {(itemsMap[brand.id] || []).map(item => ( + + + + + + + +

Part Number: {item.attributes.part_number}

+

Brand: {item.attributes.brand}

+

Category: {item.attributes.category} > {item.attributes.subcategory}

+

Dimensions: {item.attributes.dimensions?.[0]?.length} x {item.attributes.dimensions?.[0]?.width} x {item.attributes.dimensions?.[0]?.height} in

+
+
+
+
+ ))} +
+ )} +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/app/routes/app.settings.jsx b/app/routes/app.settings.jsx index f9948a9..6f8924e 100644 --- a/app/routes/app.settings.jsx +++ b/app/routes/app.settings.jsx @@ -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 ( - + + @@ -206,3 +208,220 @@ export default function SettingsPage() { ); } + +/* +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 = ( + + + + + Connected Shop: {shopName} + +
+ + + + + +
+ + {actionData?.error && ( + + )} + + {displayToken && ( + + + ✅ Access token: + + + {displayToken} + + + )} +
+
+
+
+ ); + + return standalone ? {content} : content; +} + */ \ No newline at end of file diff --git a/app/shopify.server.js b/app/shopify.server.js index a11bf9d..c73d8f6 100644 --- a/app/shopify.server.js +++ b/app/shopify.server.js @@ -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 || "", diff --git a/package-lock.json b/package-lock.json index eb7d5bd..9fb90fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index adc02f9..a639117 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af4a01d..37cc464 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { 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" } model Session { - id String @id + id String @id shop String state String isOnline Boolean @default(false) @@ -32,12 +26,12 @@ model Session { } model Turn14Credential { - id String @id @default(cuid()) - shop String @unique - clientId String - clientSecret String - accessToken String - expiresAt DateTime - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + shop String @unique + clientId String + clientSecret String + accessToken String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } diff --git a/shopify.app.toml b/shopify.app.toml index 142e22b..5761663 100644 --- a/shopify.app.toml +++ b/shopify.app.toml @@ -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