From 6b46600fff8cb0d64ff4ee9a19959d7c1ee1ae37 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Wed, 10 Jun 2026 02:23:09 +0530 Subject: [PATCH] feat: complete UI/UX rework + live import dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.dashboard.jsx (new): live import progress dashboard modelled on Race-Nation — job selector, progress bar, 6-stat grid, stage board, current product banner, activity log, errors list, completion summary, 3s polling, cancel button - app.jsx: add nav links for Dashboard, Settings, Brands, Manage Brands, Help - app._index.jsx: dark gradient hero header, subscription status bar, navcard grid, billing modal preserved - app.settings.jsx: dark header, Turn14 connect card with live status, visual pricing type toggle (MAP vs percentage) - app.brands.jsx: dark header, visual brand grid with checkbox state, sticky save toolbar - app.managebrand.jsx: dark header, live import status bar with Dashboard link, collapsible brand rows, filter toggle pills, modern product cards with attribute badges - app.help.jsx: dark header, animated FAQ accordion, styled contact card Co-Authored-By: Claude Sonnet 4.6 --- app/routes/app._index.jsx | 621 ++++--------- app/routes/app.brands.jsx | 704 ++++----------- app/routes/app.dashboard.jsx | 488 +++++++++++ app/routes/app.help.jsx | 167 ++-- app/routes/app.jsx | 2 +- app/routes/app.managebrand.jsx | 1493 +++++++++----------------------- app/routes/app.settings.jsx | 302 ++++--- 7 files changed, 1477 insertions(+), 2300 deletions(-) create mode 100644 app/routes/app.dashboard.jsx diff --git a/app/routes/app._index.jsx b/app/routes/app._index.jsx index 2d85509..46ca969 100644 --- a/app/routes/app._index.jsx +++ b/app/routes/app._index.jsx @@ -1,74 +1,47 @@ import React, { useMemo, useState } from "react"; import { json } from "@remix-run/node"; -import { useLoaderData, useActionData, useSubmit, Form } from "@remix-run/react"; +import { useLoaderData, useActionData, useSubmit, Form, useNavigate } from "@remix-run/react"; import { Page, - Layout, - Card, - BlockStack, - Text, - InlineStack, - Image, - Divider, - Button, Modal, TextField, - Box, - Banner, + BlockStack, + InlineStack, ChoiceList, - Badge, + Banner, + Button, + Divider, } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react"; -import data4autosLogo from "../assets/data4autos_logo.png"; -import turn14DistributorLogo from "../assets/turn14-logo.png"; import { authenticate } from "../shopify.server"; -/* =========================== - PRICING (single source of truth) - =========================== */ +/* ─── pricing constants ────────────────────────────────────────────────────── */ const PLAN_NAME = "Starter Sync"; -const MONTHLY_AMOUNT = 79; // USD -const ANNUAL_AMOUNT = 790; // USD +const MONTHLY_AMOUNT = 79; +const ANNUAL_AMOUNT = 790; const TRIAL_DAYS = 14; const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"]; -/* =========================== - HELPERS - =========================== */ +/* ─── helpers ──────────────────────────────────────────────────────────────── */ function formatMoney(amount, currencyCode = "USD") { if (amount == null) return "N/A"; return `${currencyCode} ${Number(amount).toFixed(2)}`; } function getIntervalLabel(interval) { - switch (interval) { - case "ANNUAL": - return "Every 12 months"; - case "EVERY_30_DAYS": - return "Every 30 days"; - default: - return interval || "N/A"; - } + if (interval === "ANNUAL") return "Every 12 months"; + if (interval === "EVERY_30_DAYS") return "Every 30 days"; + return interval || "N/A"; } function getStatusTone(status) { - switch (status) { - case "ACTIVE": - return "success"; - case "TRIAL": - return "info"; - case "CANCELLED": - case "EXPIRED": - case "DECLINED": - return "critical"; - default: - return "attention"; - } + if (status === "ACTIVE") return { bg: "#f0fdf4", border: "#bbf7d0", color: "#15803d" }; + if (status === "TRIAL") return { bg: "#eff6ff", border: "#bfdbfe", color: "#1d4ed8" }; + if (["CANCELLED", "EXPIRED", "DECLINED"].includes(status)) return { bg: "#fff1f2", border: "#fecdd3", color: "#dc2626" }; + return { bg: "#fefce8", border: "#fde68a", color: "#d97706" }; } -/* =========================== - LOADER: fetch real subscription details - =========================== */ +/* ─── loader ────────────────────────────────────────────────────────────────── */ export const loader = async ({ request }) => { const { admin, session } = await authenticate.admin(request); const shop = session.shop; @@ -77,13 +50,7 @@ export const loader = async ({ request }) => { query CurrentSubscriptionDetails { currentAppInstallation { activeSubscriptions { - id - name - status - test - createdAt - trialDays - currentPeriodEnd + id name status test createdAt trialDays currentPeriodEnd lineItems { id plan { @@ -91,10 +58,7 @@ export const loader = async ({ request }) => { __typename ... on AppRecurringPricing { interval - price { - amount - currencyCode - } + price { amount currencyCode } } } } @@ -105,75 +69,33 @@ export const loader = async ({ request }) => { `); const result = await resp.json(); - - const subscriptions = - result?.data?.currentAppInstallation?.activeSubscriptions || []; - - const subscription = - subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || - subscriptions[0] || - null; - - const recurringPricing = - subscription?.lineItems?.find( - (item) => - item?.plan?.pricingDetails?.__typename === "AppRecurringPricing" - )?.plan?.pricingDetails || null; - - const isSubscribed = - !!subscription && ALLOWED_STATUSES.includes(subscription.status); - - const subscriptionDetails = subscription - ? { - id: subscription.id, - name: subscription.name || PLAN_NAME, - status: subscription.status, - test: subscription.test ?? false, - createdAt: subscription.createdAt, - trialDays: subscription.trialDays ?? 0, - currentPeriodEnd: subscription.currentPeriodEnd, - interval: recurringPricing?.interval || null, - priceAmount: recurringPricing?.price?.amount || null, - currencyCode: recurringPricing?.price?.currencyCode || "USD", - } - : null; - - console.log( - `Loader subscription details for ${shop}: ${JSON.stringify(subscriptionDetails)}` - ); + const subscriptions = result?.data?.currentAppInstallation?.activeSubscriptions || []; + const subscription = subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || subscriptions[0] || null; + const recurringPricing = subscription?.lineItems?.find((item) => item?.plan?.pricingDetails?.__typename === "AppRecurringPricing")?.plan?.pricingDetails || null; + const isSubscribed = !!subscription && ALLOWED_STATUSES.includes(subscription.status); + const subscriptionDetails = subscription ? { + id: subscription.id, name: subscription.name || PLAN_NAME, status: subscription.status, + test: subscription.test ?? false, createdAt: subscription.createdAt, + trialDays: subscription.trialDays ?? 0, currentPeriodEnd: subscription.currentPeriodEnd, + interval: recurringPricing?.interval || null, priceAmount: recurringPricing?.price?.amount || null, + currencyCode: recurringPricing?.price?.currencyCode || "USD", + } : null; if (shop === "racewerksengg.myshopify.com") { - return json({ - redirectToBilling: false, - shop, - isSubscribed: true, - subscription: subscriptionDetails, - allSubscriptions: subscriptions, - }); + return json({ redirectToBilling: false, shop, isSubscribed: true, subscription: subscriptionDetails, allSubscriptions: subscriptions }); } - return json({ - redirectToBilling: !isSubscribed, - shop, - isSubscribed, - subscription: subscriptionDetails, - allSubscriptions: subscriptions, - }); + return json({ redirectToBilling: !isSubscribed, shop, isSubscribed, subscription: subscriptionDetails, allSubscriptions: subscriptions }); }; -/* =========================== - ACTION: create subscription - =========================== */ +/* ─── action ────────────────────────────────────────────────────────────────── */ export const action = async ({ request }) => { const { admin, session } = await authenticate.admin(request); const form = await request.formData(); - const rawCadence = form.get("cadence"); const cadence = rawCadence === "ANNUAL" ? "ANNUAL" : "MONTHLY"; - const interval = cadence === "ANNUAL" ? "ANNUAL" : "EVERY_30_DAYS"; const amount = cadence === "ANNUAL" ? ANNUAL_AMOUNT : MONTHLY_AMOUNT; - const shop = session.shop; const shopDomain = (shop || "").split(".")[0]; const returnUrl = `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app`; @@ -183,64 +105,58 @@ export const action = async ({ request }) => { appSubscriptionCreate( name: "${PLAN_NAME} - ${cadence === "ANNUAL" ? "Annual" : "Monthly"}" returnUrl: "${returnUrl}" - lineItems: [ - { - plan: { - appRecurringPricingDetails: { - price: { amount: ${amount}, currencyCode: USD } - interval: ${interval} - } - } - } - ] + lineItems: [{ plan: { appRecurringPricingDetails: { price: { amount: ${amount}, currencyCode: USD } interval: ${interval} } } }] trialDays: ${TRIAL_DAYS} replacementBehavior: STANDARD test: false ) { confirmationUrl - appSubscription { - id - name - status - trialDays - } - userErrors { - field - message - } + appSubscription { id name status trialDays } + userErrors { field message } } } `); const data = await createRes.json(); - const url = data?.data?.appSubscriptionCreate?.confirmationUrl || null; const userErrors = data?.data?.appSubscriptionCreate?.userErrors || []; const topLevelErrors = data?.errors || []; if (!url || userErrors.length || topLevelErrors.length) { - return json( - { - errors: [ - "Failed to create subscription.", - ...userErrors.map((e) => e.message), - ...topLevelErrors.map((e) => e.message || String(e)), - ], - }, - { status: 400 } - ); + return json({ errors: ["Failed to create subscription.", ...userErrors.map((e) => e.message), ...topLevelErrors.map((e) => e.message || String(e))] }, { status: 400 }); } - return json({ confirmationUrl: url }); }; -/* =========================== - PAGE - =========================== */ +/* ─── NavCard ────────────────────────────────────────────────────────────────── */ +function NavCard({ icon, title, desc, link, accent }) { + const colors = { + blue: { bg: "#eff6ff", border: "#bfdbfe", icon: "#2563eb" }, + green: { bg: "#f0fdf4", border: "#bbf7d0", icon: "#16a34a" }, + purple: { bg: "#faf5ff", border: "#e9d5ff", icon: "#7c3aed" }, + amber: { bg: "#fffbeb", border: "#fde68a", icon: "#b45309" }, + }; + const c = colors[accent] || colors.blue; + return ( + +
{ e.currentTarget.style.transform = "translateY(-2px)"; e.currentTarget.style.boxShadow = "0 8px 20px rgba(0,0,0,0.08)"; }} + onMouseLeave={(e) => { e.currentTarget.style.transform = ""; e.currentTarget.style.boxShadow = ""; }} + > +
{icon}
+
{title}
+
{desc}
+
+
+ ); +} + +/* ─── Page ───────────────────────────────────────────────────────────────────── */ export default function Index() { const actionData = useActionData(); const loaderData = useLoaderData(); const submit = useSubmit(); + const navigate = useNavigate(); const [activeModal, setActiveModal] = useState(false); const [cadence, setCadence] = useState("MONTHLY"); @@ -250,275 +166,143 @@ export default function Index() { const hasConfirmationUrl = Boolean(actionData?.confirmationUrl); const errors = actionData?.errors || []; - const shopDomain = (shop || "").split(".")[0]; - const items = [ - { - icon: "⚙️", - text: "Manage API settings", - link: `/d4a-turn14/app/settings`, - }, - { - icon: "🏷️", - text: "Browse and import available brands", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`, - }, - { - icon: "📦", - text: "Sync brand collections to Shopify", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`, - }, - { - icon: "🔐", - text: "Handle secure Turn14 login credentials", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`, - }, - ]; - - const openModal = () => setActiveModal(true); - const closeModal = () => setActiveModal(false); - - const formatDate = (d) => - d - ? new Date(d).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }) - : "N/A"; + const formatDate = (d) => d ? new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : "N/A"; const previewBase = subscription?.createdAt ? new Date(subscription.createdAt) : new Date(); const previewTrialEnd = new Date(previewBase); previewTrialEnd.setDate(previewTrialEnd.getDate() + TRIAL_DAYS); const trialDaysLeft = useMemo(() => { - if (!subscription?.trialDays || !subscription?.createdAt) return null; - if (subscription.status !== "TRIAL") return null; - + if (!subscription?.trialDays || !subscription?.createdAt || subscription.status !== "TRIAL") return null; const created = new Date(subscription.createdAt); const trialEnd = new Date(created); trialEnd.setDate(trialEnd.getDate() + subscription.trialDays); - - const now = new Date(); - const msLeft = trialEnd.getTime() - now.getTime(); - const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); - - return Math.max(0, daysLeft); + const msLeft = trialEnd.getTime() - Date.now(); + return Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24))); }, [subscription?.trialDays, subscription?.createdAt, subscription?.status]); const isPreviewMode = !isSubscribed; - const displayPlan = isPreviewMode - ? `${PLAN_NAME} — ${cadence === "ANNUAL" ? "Annual" : "Monthly"}` - : subscription?.name || PLAN_NAME; + const displayPlan = isPreviewMode ? `${PLAN_NAME} — ${cadence === "ANNUAL" ? "Annual" : "Monthly"}` : subscription?.name || PLAN_NAME; + const displayInterval = isPreviewMode ? (cadence === "ANNUAL" ? "Every 12 months" : "Every 30 days") : getIntervalLabel(subscription?.interval); + const displayPrice = isPreviewMode ? (cadence === "ANNUAL" ? `$${ANNUAL_AMOUNT}/yr` : `$${MONTHLY_AMOUNT}/mo`) : (subscription?.priceAmount ? `${formatMoney(subscription.priceAmount, subscription.currencyCode)}${subscription?.interval === "ANNUAL" ? "/yr" : "/mo"}` : "N/A"); + const displayNextRenewal = isPreviewMode ? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)` : formatDate(subscription?.currentPeriodEnd); + const displayStatus = isPreviewMode ? "Not active" : subscription?.status || "N/A"; - const displayInterval = isPreviewMode - ? cadence === "ANNUAL" - ? "Every 12 months" - : "Every 30 days" - : getIntervalLabel(subscription?.interval); + const statusStyle = getStatusTone(subscription?.status || "PENDING"); - const displayPrice = isPreviewMode - ? cadence === "ANNUAL" - ? `$${ANNUAL_AMOUNT}/yr` - : `$${MONTHLY_AMOUNT}/mo` - : subscription?.priceAmount - ? `${formatMoney(subscription.priceAmount, subscription.currencyCode)}${ - subscription?.interval === "ANNUAL" ? "/yr" : "/mo" - }` - : "N/A"; - - const displayNextRenewal = isPreviewMode - ? `${formatDate(previewTrialEnd)} (after ${TRIAL_DAYS}-day trial)` - : formatDate(subscription?.currentPeriodEnd); - - const displayStatus = isPreviewMode - ? "Not active — will be created at checkout" - : subscription?.status || "N/A"; + const navItems = [ + { icon: "⚙️", title: "Settings", desc: "Connect Turn14 API & configure pricing", link: `/app/settings`, accent: "purple" }, + { icon: "🏷️", title: "Browse Brands", desc: "Select brands to sync from Turn14", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`, accent: "blue" }, + { icon: "📦", title: "Manage Brands", desc: "Import products from selected brands", link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`, accent: "green" }, + { icon: "📊", title: "Dashboard", desc: "Track live import progress & stats", link: `/app/dashboard`, accent: "amber" }, + ]; return ( - - - - - - - Welcome to your Turn14 Dashboard - + {/* Hero header */} +
+
+
+
+
Data4Autos
+
Turn14 Distribution Integration
+
+ Sync product brands, manage collections, and automate catalog setup directly from Turn14 into your Shopify store. +
+
+ + +
+
+
- - Data4Autos Logo - Turn14 Distributors Logo - - + {/* Subscription status bar */} +
+
+
Subscription Status
+
{isSubscribed ? subscription?.status : "NOT SUBSCRIBED"}
+
+
+
+
Plan
+
{displayPlan}
+
+
+
+
Price
+
{displayPrice}
+
+ {trialDaysLeft != null && ( + <> +
+
+
Trial Days Left
+
{trialDaysLeft} days
+
+ + )} +
+ +
+
- + {/* Nav cards grid */} +
+ {navItems.map((item) => ( + + ))} +
- - - - Subscription Overview - - - {isSubscribed ? subscription?.status : "NOT SUBSCRIBED"} - - - - - - - Plan: {displayPlan} - - - Billing: {displayInterval} - - - Price: {displayPrice} - - - Next renewal / period end: {displayNextRenewal} - - - Trial:{" "} - {isSubscribed - ? `${subscription?.trialDays || 0} day(s)` - : `${TRIAL_DAYS} day free trial`} - - - Trial days left:{" "} - {trialDaysLeft != null ? `${trialDaysLeft} day(s)` : "N/A"} - - - - - - - - - - 🚀 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: - - - - {items.map((item, index) => ( - - - - {item.icon} - - - - - {item.text} - - - - - ))} - - - - - - - - - Need help? Contact us at{" "} - support@data4autos.com - - - - - - - - + {/* Footer help */} +
+ Need help? Email support@data4autos.com or visit the Help page +
+ {/* Billing modal */} setActiveModal(false)} title="Subscription Details" primaryAction={ - hasConfirmationUrl - ? undefined - : isSubscribed - ? undefined - : { - content: - cadence === "ANNUAL" - ? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr` - : `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`, - onAction: () => { - const form = document.getElementById("billing-form"); - if (form) { - const hidden = document.getElementById("cadence-field"); - if (hidden) hidden.value = cadence; - submit(form, { method: "post" }); - } - }, - } + hasConfirmationUrl ? undefined : isSubscribed ? undefined : { + content: cadence === "ANNUAL" ? `Start ${TRIAL_DAYS}-day Trial — $${ANNUAL_AMOUNT}/yr` : `Start ${TRIAL_DAYS}-day Trial — $${MONTHLY_AMOUNT}/mo`, + onAction: () => { + const form = document.getElementById("billing-form"); + if (form) { + const hidden = document.getElementById("cadence-field"); + if (hidden) hidden.value = cadence; + submit(form, { method: "post" }); + } + }, + } } - secondaryActions={[{ content: "Close", onAction: closeModal }]} + secondaryActions={[{ content: "Close", onAction: () => setActiveModal(false) }]} >
- {errors.length > 0 && ( - +
    - {errors.map((e, i) => ( -
  • {e}
  • - ))} + {errors.map((e, i) =>
  • {e}
  • )}
)} @@ -529,63 +313,23 @@ export default function Index() { - - - - - {subscription?.createdAt && ( - - )} - + + + + {subscription?.createdAt && } - {!isSubscribed && ( <> - - Choose your billing plan - - +
Choose your billing plan
{ - const next = - Array.isArray(selected) && selected[0] - ? selected[0] - : "MONTHLY"; + const next = Array.isArray(selected) && selected[0] ? selected[0] : "MONTHLY"; setCadence(next); const hidden = document.getElementById("cadence-field"); if (hidden) hidden.value = next; @@ -600,27 +344,12 @@ export default function Index() { {hasConfirmationUrl && ( - Click the button below to open Shopify’s billing confirmation in a - new tab. + Click the button below to open Shopify's billing confirmation in a new tab. - - - - + {actionData.confirmationUrl} @@ -631,4 +360,4 @@ export default function Index() { ); -} \ No newline at end of file +} diff --git a/app/routes/app.brands.jsx b/app/routes/app.brands.jsx index 17da40c..caf1e21 100644 --- a/app/routes/app.brands.jsx +++ b/app/routes/app.brands.jsx @@ -7,7 +7,6 @@ import { TextField, Checkbox, Button, - Thumbnail, Spinner, Toast, Frame, @@ -25,9 +24,7 @@ const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"]; async function checkShopExists(shop) { try { - const resp = await fetch( - `https://backend.data4autos.com/checkisshopdataexists/${shop}` - ); + const resp = await fetch(`https://backend.data4autos.com/checkisshopdataexists/${shop}`); const data = await resp.json(); return data.status === 1; } catch (err) { @@ -37,14 +34,9 @@ async function checkShopExists(shop) { } function getIntervalLabel(interval) { - switch (interval) { - case "ANNUAL": - return "Every 12 months"; - case "EVERY_30_DAYS": - return "Every 30 days"; - default: - return interval || "N/A"; - } + if (interval === "ANNUAL") return "Every 12 months"; + if (interval === "EVERY_30_DAYS") return "Every 30 days"; + return interval || "N/A"; } function formatMoney(amount, currencyCode = "USD") { @@ -54,28 +46,17 @@ function formatMoney(amount, currencyCode = "USD") { function formatDate(date) { if (!date) return "N/A"; - return new Date(date).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); + return new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } async function getSubscriptionDetails(request) { const { admin, session } = await authenticate.admin(request); const shop = session.shop; - const resp = await admin.graphql(` query CurrentSubscriptionDetails { currentAppInstallation { activeSubscriptions { - id - name - status - test - createdAt - trialDays - currentPeriodEnd + id name status test createdAt trialDays currentPeriodEnd lineItems { id plan { @@ -83,10 +64,7 @@ async function getSubscriptionDetails(request) { __typename ... on AppRecurringPricing { interval - price { - amount - currencyCode - } + price { amount currencyCode } } } } @@ -95,216 +73,87 @@ async function getSubscriptionDetails(request) { } } `); - const result = await resp.json(); - - const subscriptions = - result?.data?.currentAppInstallation?.activeSubscriptions || []; - - const subscription = - subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || - subscriptions[0] || - null; - - const recurringPricing = - subscription?.lineItems?.find( - (item) => - item?.plan?.pricingDetails?.__typename === "AppRecurringPricing" - )?.plan?.pricingDetails || null; - - const isSubscribed = - !!subscription && ALLOWED_STATUSES.includes(subscription.status); - + const subscriptions = result?.data?.currentAppInstallation?.activeSubscriptions || []; + const subscription = subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || subscriptions[0] || null; + const recurringPricing = subscription?.lineItems?.find((item) => item?.plan?.pricingDetails?.__typename === "AppRecurringPricing")?.plan?.pricingDetails || null; + const isSubscribed = !!subscription && ALLOWED_STATUSES.includes(subscription.status); return { shop, isSubscribed, - subscription: subscription - ? { - id: subscription.id, - name: subscription.name || PLAN_NAME, - status: subscription.status, - test: subscription.test ?? false, - createdAt: subscription.createdAt, - trialDays: subscription.trialDays ?? 0, - currentPeriodEnd: subscription.currentPeriodEnd, - interval: recurringPricing?.interval || null, - priceAmount: recurringPricing?.price?.amount || null, - currencyCode: recurringPricing?.price?.currencyCode || "USD", - } - : null, + subscription: subscription ? { + id: subscription.id, name: subscription.name || PLAN_NAME, status: subscription.status, + test: subscription.test ?? false, createdAt: subscription.createdAt, + trialDays: subscription.trialDays ?? 0, currentPeriodEnd: subscription.currentPeriodEnd, + interval: recurringPricing?.interval || null, priceAmount: recurringPricing?.price?.amount || null, + currencyCode: recurringPricing?.price?.currencyCode || "USD", + } : null, }; } export const loader = async ({ request }) => { console.log("🚀 Loader started"); - let admin, session, shop; - try { const authResult = await authenticate.admin(request); admin = authResult.admin; session = authResult.session; shop = session?.shop; - - console.log("✅ Shopify auth success"); - console.log("🏪 Shop:", shop); } catch (err) { - console.error("❌ Shopify authentication failed:", err); - return json({ - brands: [], - collections: [], - selectedBrandsFromShopify: [], - shop: "", - error: "Shopify authentication failed", - isSubscribed: false, - subscription: null, - }); + return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop: "", error: "Shopify authentication failed", isSubscribed: false, subscription: null }); } const { isSubscribed, subscription } = await getSubscriptionDetails(request); - let accessToken = ""; - try { - console.log("🔑 Fetching Turn14 access token from metafield..."); accessToken = await getTurn14AccessTokenFromMetafield(request); - console.log("✅ Turn14 access token received:", accessToken ? "YES" : "EMPTY"); } catch (err) { - console.error("❌ Error getting Turn14 access token:", err); - return json({ - brands: [], - collections: [], - selectedBrandsFromShopify: [], - shop, - error: "Failed to fetch Turn14 access token", - isSubscribed, - subscription, - }); + return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, error: "Failed to fetch Turn14 access token", isSubscribed, subscription }); } let brandJson; try { - const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - + const brandRes = await fetch("https://turn14.data4autos.com/v1/brands", { headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" } }); brandJson = await brandRes.json(); - - if (!brandRes.ok) { - return json({ - brands: [], - collections: [], - selectedBrandsFromShopify: [], - shop, - error: brandJson?.error || "Failed to fetch brands", - isSubscribed, - subscription, - }); - } + if (!brandRes.ok) return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, error: brandJson?.error || "Failed to fetch brands", isSubscribed, subscription }); } catch (err) { - console.error("❌ Exception while fetching Turn14 brands:", err); - return json({ - brands: [], - collections: [], - selectedBrandsFromShopify: [], - shop, - error: "Turn14 brands fetch crashed", - isSubscribed, - subscription, - }); + return json({ brands: [], collections: [], selectedBrandsFromShopify: [], shop, error: "Turn14 brands fetch crashed", isSubscribed, subscription }); } let collections = []; try { - const gqlRaw = await admin.graphql(` - { - collections(first: 100) { - edges { - node { - id - title - } - } - } - } - `); - + const gqlRaw = await admin.graphql(`{ collections(first: 100) { edges { node { id title } } } }`); const gql = await gqlRaw.json(); collections = gql?.data?.collections?.edges?.map((e) => e.node) || []; - } catch (err) { - console.error("❌ Error fetching Shopify collections:", err); - } + } catch (err) {} let selectedBrands = []; - try { - const res = await admin.graphql(` - { - shop { - metafield(namespace: "turn14", key: "selected_brands") { - value - } - } - } - `); - + 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; + if (rawValue) selectedBrands = JSON.parse(rawValue); + } catch (err) {} - if (rawValue) { - selectedBrands = JSON.parse(rawValue); - } - } catch (err) { - console.error("❌ Failed parsing selected_brands metafield:", err); - } - - return json({ - brands: brandJson?.data || [], - collections, - selectedBrandsFromShopify: selectedBrands, - shop, - isSubscribed, - subscription, - }); + return json({ brands: brandJson?.data || [], collections, selectedBrandsFromShopify: selectedBrands, shop, isSubscribed, subscription }); }; export const action = async ({ request }) => { const { isSubscribed } = await getSubscriptionDetails(request); - - if (!isSubscribed) { - return json( - { - error: - "An active subscription or free trial is required to save brand collections.", - }, - { status: 403 } - ); - } + if (!isSubscribed) return json({ error: "An active subscription or free trial is required to save brand collections." }, { status: 403 }); const formData = await request.formData(); const selectedBrands = JSON.parse(formData.get("selectedBrands") || "[]"); const selectedOldBrands = JSON.parse(formData.get("selectedOldBrands") || "[]"); - const { session } = await authenticate.admin(request); const shop = session.shop; - selectedBrands.forEach((brand) => { - delete brand.pricegroups; - }); - - selectedOldBrands.forEach((brand) => { - delete brand.pricegroups; - }); + selectedBrands.forEach((brand) => { delete brand.pricegroups; }); + selectedOldBrands.forEach((brand) => { delete brand.pricegroups; }); const resp = await fetch("https://backend.data4autos.com/managebrands", { method: "POST", - headers: { - "Content-Type": "application/json", - "shop-domain": shop, - }, + headers: { "Content-Type": "application/json", "shop-domain": shop }, body: JSON.stringify({ shop, selectedBrands, selectedOldBrands }), }); @@ -312,47 +161,31 @@ export const action = async ({ request }) => { const err = await resp.text(); return json({ error: err }, { status: resp.status }); } - const { processId, status } = await resp.json(); return json({ processId, status }); }; export default function BrandsPage() { const navigate = useNavigate(); - const { - brands = [], - selectedBrandsFromShopify = [], - shop = "", - error, - isSubscribed = false, - subscription = null, - } = useLoaderData() || {}; - + const { brands = [], selectedBrandsFromShopify = [], shop = "", error, isSubscribed = false, subscription = null } = useLoaderData() || {}; const actionData = useActionData() || {}; const [selectedIdsold, setSelectedIdsold] = useState([]); - const [selectedIds, setSelectedIds] = useState(() => - (selectedBrandsFromShopify ?? []).map((b) => b.id) - ); + const [selectedIds, setSelectedIds] = useState(() => (selectedBrandsFromShopify ?? []).map((b) => b.id)); const [search, setSearch] = useState(""); const [filteredBrands, setFilteredBrands] = useState(brands); const [toastActive, setToastActive] = useState(false); const [polling, setPolling] = useState(false); const [status, setStatus] = useState(actionData.status || ""); const [Turn14Enabled, setTurn14Enabled] = useState(null); + const [saving, setSaving] = useState(false); useEffect(() => { if (!shop) return; - - (async () => { - const result = await checkShopExists(shop); - setTurn14Enabled(result); - })(); + (async () => { const result = await checkShopExists(shop); setTurn14Enabled(result); })(); }, [shop]); - useEffect(() => { - setSelectedIdsold(selectedIds); - }, [toastActive]); + useEffect(() => { setSelectedIdsold(selectedIds); }, [toastActive]); useEffect(() => { const term = search.toLowerCase(); @@ -360,157 +193,64 @@ export default function BrandsPage() { }, [search, brands]); useEffect(() => { - if (actionData.status) { - setStatus(actionData.status); - setToastActive(true); - } - }, [actionData.status]); + if (actionData.status) { setStatus(actionData.status); setToastActive(true); setSaving(false); } + if (actionData.error) setSaving(false); + }, [actionData.status, actionData.error]); const checkStatus = async () => { if (!actionData.processId) return; setPolling(true); - const resp = await fetch( - `https://backend.data4autos.com/managebrands/status/${actionData.processId}`, - { headers: { "shop-domain": window.shopify.shop || "" } } - ); + const resp = await fetch(`https://backend.data4autos.com/managebrands/status/${actionData.processId}`, { headers: { "shop-domain": window.shopify?.shop || "" } }); const jsonBody = await resp.json(); - setStatus( - jsonBody.status + (jsonBody.detail ? ` (${jsonBody.detail})` : "") - ); + setStatus(jsonBody.status + (jsonBody.detail ? ` (${jsonBody.detail})` : "")); setPolling(false); }; const toggleSelect = (id) => { if (!isSubscribed) return; - - setSelectedIds((prev) => - prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id] - ); + setSelectedIds((prev) => prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]); }; - const allFilteredSelected = - filteredBrands.length > 0 && - filteredBrands.every((b) => selectedIds.includes(b.id)); - + const allFilteredSelected = filteredBrands.length > 0 && filteredBrands.every((b) => selectedIds.includes(b.id)); const toggleSelectAll = () => { if (!isSubscribed) return; - const ids = filteredBrands.map((b) => b.id); - if (allFilteredSelected) { - setSelectedIds((prev) => prev.filter((id) => !ids.includes(id))); - } else { - setSelectedIds((prev) => Array.from(new Set([...prev, ...ids]))); - } + if (allFilteredSelected) setSelectedIds((prev) => prev.filter((id) => !ids.includes(id))); + else setSelectedIds((prev) => Array.from(new Set([...prev, ...ids]))); }; - let isSubmitting = false; - if (actionData.status) { - isSubmitting = !actionData.status && !actionData.error && !actionData.processId; - } - - const toastMarkup = toastActive ? ( - setToastActive(false)} - /> - ) : null; - - const selectedBrands = brands.filter((b) => selectedIds.includes(b.id)); - const selectedOldBrands = brands.filter((b) => selectedIdsold.includes(b.id)); - - const shopDomain = (shop || "").split(".")[0]; - - const items = [ - { - icon: "⚙️", - text: "Manage API settings", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings`, - }, - { - icon: "🏷️", - text: "Browse and import available brands", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`, - }, - { - icon: "📦", - text: "Sync brand collections to Shopify", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`, - }, - { - icon: "🔐", - text: "Handle secure Turn14 login credentials", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`, - }, - ]; - const trialDaysLeft = useMemo(() => { - if (!subscription?.trialDays || !subscription?.createdAt) return null; - if (subscription.status !== "TRIAL") return null; - + if (!subscription?.trialDays || !subscription?.createdAt || subscription.status !== "TRIAL") return null; const created = new Date(subscription.createdAt); const trialEnd = new Date(created); trialEnd.setDate(trialEnd.getDate() + subscription.trialDays); - - const now = new Date(); - const msLeft = trialEnd.getTime() - now.getTime(); - const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); - - return Math.max(0, daysLeft); + return Math.max(0, Math.ceil((trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))); }, [subscription]); + const selectedBrands = brands.filter((b) => selectedIds.includes(b.id)); + const selectedOldBrands = brands.filter((b) => selectedIdsold.includes(b.id)); + const shopDomain = (shop || "").split(".")[0]; + if (Turn14Enabled === false) { return ( - - - - -
- - Turn14 isn’t connected yet - -
- - This shop hasn’t been configured with Turn14 / Data4Autos. To get started, open Settings and complete the connection. - -
- - - -
- - Once connected, you’ll be able to browse brands and sync collections. - -
- - -
-
-
-
+ +
+
🏷️ Brand Selection
+
{shop}
+
+ +
+
🔌
+ Turn14 not connected yet +
Complete the connection in Settings before browsing brands.
+
+ + +
+
+
); @@ -519,211 +259,131 @@ export default function BrandsPage() { return ( - + -
- - Data4Autos Turn14 Brands List - + {/* Header */} +
+
+
🏷️ Brand Selection
+
{filteredBrands.length} brands available · {selectedIds.length} selected
+
+ {!isSubscribed && ( +
+ ⚠️ Subscription required +
+ )}
- - {!isSubscribed && ( - - -

- This feature is available only for merchants with an active - subscription or during the free trial period. -

-
-

- Current status:{" "} - {subscription?.status || "Not subscribed"} -

-

- Plan: {subscription?.name || PLAN_NAME} -

-

- Billing: {getIntervalLabel(subscription?.interval)} -

-

- Price:{" "} - {formatMoney( - subscription?.priceAmount, - subscription?.currencyCode - )} -

-

- Next renewal / period end:{" "} - {formatDate(subscription?.currentPeriodEnd)} -

-

- Trial days left:{" "} - {trialDaysLeft != null ? `${trialDaysLeft} day(s)` : "N/A"} -

-
-
- - - -
-
-
- )} + {/* Subscription warning */} + {!isSubscribed && ( +
+
⚠️ Active subscription required
+
Brand selection is only available with an active subscription or free trial.
+ +
+ )} - {error && ( - - -

{error}

-
-
- )} + {error && ( +
+ ⛔ {error} +
+ )} - {actionData?.error && ( - - -

{actionData.error}

-
-
- )} + {actionData?.error && ( +
+ ⛔ {actionData.error} +
+ )} - -
-
-
- {(actionData?.processId || false) && ( -
-

- Process ID: {actionData.processId} -

-

- Status: {status || "—"} -

- -
- )} - -
- -
- - -
- - - - - - + {/* Sticky toolbar */} +
+
+
+ +
+
+ + Select All +
+
+ {selectedIds.length} selected +
+ {(actionData?.processId) && ( +
+
Status: {status || "—"}
+
-
- + )} +
- -
setSaving(true)}> + + + + +
-
- -
- -
- {brand.name} -
+ {/* Brand grid */} +
+ {filteredBrands.map((brand) => { + const isSelected = selectedIds.includes(brand.id); + return ( +
toggleSelect(brand.id)} + style={{ background: isSelected ? "#eff6ff" : "#fff", border: `2px solid ${isSelected ? "#2563eb" : "#e5e7eb"}`, borderRadius: 12, padding: "16px 14px", cursor: isSubscribed ? "pointer" : "default", position: "relative", transition: "all 0.15s", boxShadow: isSelected ? "0 0 0 3px rgba(37,99,235,0.12)" : "none" }} + > + {/* Checkbox badge */} +
+
+ {isSelected && }
- - ))} -
- - +
- {toastMarkup} +
+ {brand.name} +
+ +
+ {brand.name} +
+
ID: {brand.id}
+
+ ); + })} +
+ + {filteredBrands.length === 0 && ( +
+
🔍
+
No brands found
+
Try a different search term
+
+ )} + + {toastActive && ( + setToastActive(false)} /> + )} ); -} \ No newline at end of file +} diff --git a/app/routes/app.dashboard.jsx b/app/routes/app.dashboard.jsx new file mode 100644 index 0000000..b669fa8 --- /dev/null +++ b/app/routes/app.dashboard.jsx @@ -0,0 +1,488 @@ +// app/routes/app.dashboard.jsx — Live import progress dashboard +import { useEffect, useState, useRef, useCallback } from "react"; +import { json } from "@remix-run/node"; +import { useLoaderData, useNavigate } from "@remix-run/react"; +import { Page, Layout, Card, Text, BlockStack, InlineStack, Badge, ProgressBar, Button, Spinner, Box, Divider } from "@shopify/polaris"; +import { TitleBar } from "@shopify/app-bridge-react"; +import { authenticate } from "../shopify.server"; + +const BACKEND = "https://backend.data4autos.com"; +const POLL_INTERVAL = 3000; + +export const loader = async ({ request }) => { + const { session } = await authenticate.admin(request); + return json({ shop: session.shop }); +}; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function elapsed(startedAt) { + if (!startedAt) return "—"; + const secs = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000); + if (secs < 60) return `${secs}s`; + const m = Math.floor(secs / 60), s = secs % 60; + if (m < 60) return `${m}m ${s}s`; + const h = Math.floor(m / 60), mm = m % 60; + return `${h}h ${mm}m`; +} + +function fmt(d) { + if (!d) return "—"; + return new Date(d).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function statusTone(status) { + if (!status) return "subdued"; + if (status === "done") return "success"; + if (status === "error") return "critical"; + if (status === "cancelled") return "warning"; + if (status === "importing" || status === "fetching_products") return "info"; + return "subdued"; +} + +function statusLabel(status) { + const map = { + started: "Starting", + fetching_products: "Fetching Products", + importing: "Importing", + done: "Completed", + error: "Error", + cancelled: "Cancelled", + cancelling: "Cancelling…", + }; + return map[status] || (status || "Unknown"); +} + +function stepLabel(step) { + const map = { + started: "Initialising", + fetching_products: "Fetching products from Turn14", + importing: "Creating / updating products in Shopify", + completed: "Import complete", + error: "Import stopped — error", + cancelled: "Import cancelled", + }; + return map[step] || step || "—"; +} + +function parseLogTone(line) { + if (!line) return "subdued"; + if (line.includes("[PRODUCT-OK]") || line.includes("[IMPORT-DONE]") || line.includes("[FETCH-OK]")) return "success"; + if (line.includes("[PRODUCT-FAIL]") || line.includes("[ERROR]") || line.includes("[FETCH-FAIL]")) return "critical"; + if (line.includes("[SKIP]")) return "warning"; + if (line.includes("[PRODUCT]") || line.includes("[STATS]")) return "info"; + return "subdued"; +} + +function parseLogTitle(line) { + if (!line) return "Log"; + if (line.includes("[PRODUCT-OK]")) return "Product created"; + if (line.includes("[PRODUCT-FAIL]")) return "Product failed"; + if (line.includes("[SKIP]")) return "Skipped duplicate"; + if (line.includes("[IMPORT-DONE]")) return "Import complete"; + if (line.includes("[IMPORT-START]")) return "Import started"; + if (line.includes("[FETCH-OK]")) return "Turn14 fetch success"; + if (line.includes("[FETCH-FAIL]")) return "Turn14 fetch failed"; + if (line.includes("[FETCH]")) return "Fetching from Turn14"; + if (line.includes("[STATS]")) return "Progress update"; + if (line.includes("[PRODUCT]")) return "Processing product"; + if (line.includes("[ERROR]")) return "Error"; + if (line.includes("[CANCEL]")) return "Cancelled"; + return "Activity"; +} + +const toneColors = { + success: { bg: "#f0fdf4", border: "#bbf7d0", dot: "#16a34a", text: "#166534" }, + critical: { bg: "#fff1f2", border: "#fecdd3", dot: "#dc2626", text: "#991b1b" }, + warning: { bg: "#fffbeb", border: "#fde68a", dot: "#d97706", text: "#92400e" }, + info: { bg: "#eff6ff", border: "#bfdbfe", dot: "#2563eb", text: "#1e40af" }, + subdued: { bg: "#f9fafb", border: "#e5e7eb", dot: "#9ca3af", text: "#6b7280" }, +}; + +// ─── StatCard ──────────────────────────────────────────────────────────────── +function StatCard({ label, value, sub, accent }) { + const accents = { + blue: { bg: "#eff6ff", border: "#bfdbfe", val: "#1d4ed8" }, + green: { bg: "#f0fdf4", border: "#bbf7d0", val: "#15803d" }, + red: { bg: "#fff1f2", border: "#fecdd3", val: "#dc2626" }, + amber: { bg: "#fffbeb", border: "#fde68a", val: "#b45309" }, + purple: { bg: "#faf5ff", border: "#e9d5ff", val: "#7c3aed" }, + slate: { bg: "#f8fafc", border: "#e2e8f0", val: "#475569" }, + }; + const c = accents[accent] || accents.slate; + return ( +
+
{label}
+
{value ?? "—"}
+ {sub &&
{sub}
} +
+ ); +} + +// ─── StageRow ───────────────────────────────────────────────────────────────── +function StageRow({ label, status, value, meta }) { + const dotColor = status === "done" ? "#16a34a" : status === "active" ? "#2563eb" : status === "error" ? "#dc2626" : "#d1d5db"; + const textColor = status === "active" ? "#1d4ed8" : status === "done" ? "#166534" : "#6b7280"; + return ( +
+
+
{label}
+ {meta &&
{meta}
} + {value && ( +
+ {value} +
+ )} +
+ ); +} + +// ─── LogEntry ──────────────────────────────────────────────────────────────── +function LogEntry({ entry }) { + const tone = parseLogTone(entry.line); + const title = parseLogTitle(entry.line); + const c = toneColors[tone]; + const cleanLine = entry.line.replace(/^\[[\w-]+\]\s*/, ""); + return ( +
+
+
+
{title}
+
{cleanLine}
+
+
{fmt(entry.at)}
+
+ ); +} + +// ─── Main component ────────────────────────────────────────────────────────── +export default function Dashboard() { + const { shop } = useLoaderData(); + const navigate = useNavigate(); + + const [jobs, setJobs] = useState([]); + const [selectedJobId, setSelectedJobId] = useState(null); + const [job, setJob] = useState(null); + const [loading, setLoading] = useState(true); + const [tick, setTick] = useState(0); + const [now, setNow] = useState(Date.now()); + const pollRef = useRef(null); + + // Fetch job list + const fetchJobs = useCallback(async () => { + try { + const resp = await fetch(`${BACKEND}/jobs?shop=${encodeURIComponent(shop)}`); + const data = await resp.json(); + const list = data.jobs || []; + setJobs(list); + if (!selectedJobId && list.length > 0) setSelectedJobId(list[0].id); + } catch {} + }, [shop, selectedJobId]); + + // Fetch selected job detail + const fetchJob = useCallback(async (id) => { + if (!id) return; + try { + const resp = await fetch(`${BACKEND}/jobs/${id}`); + if (!resp.ok) return; + const j = await resp.json(); + setJob(j); + } catch {} + }, []); + + // Initial load + useEffect(() => { + (async () => { + setLoading(true); + await fetchJobs(); + setLoading(false); + })(); + }, []); + + // Select job when list arrives + useEffect(() => { + if (!selectedJobId && jobs.length > 0) setSelectedJobId(jobs[0].id); + }, [jobs]); + + // Poll selected job + useEffect(() => { + if (!selectedJobId) return; + fetchJob(selectedJobId); + pollRef.current = setInterval(async () => { + await fetchJob(selectedJobId); + setTick(t => t + 1); + }, POLL_INTERVAL); + return () => clearInterval(pollRef.current); + }, [selectedJobId]); + + // Elapsed timer + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + const handleCancel = async () => { + if (!selectedJobId) return; + await fetch(`${BACKEND}/jobs/${selectedJobId}/cancel`, { method: "POST" }); + fetchJob(selectedJobId); + }; + + const isRunning = job && (job.status === "importing" || job.status === "fetching_products" || job.status === "started"); + + const s = job?.liveStats || { total: 0, processed: 0, created: 0, skipped: 0, failed: 0, remaining: 0, successRate: 0 }; + const progress = s.total > 0 ? Math.round((s.processed / s.total) * 100) : 0; + const elapsedStr = job?.startedAt ? elapsed(job.startedAt) : "—"; + + // Stage states + const stageStatus = (stageName) => { + if (!job) return "pending"; + if (job.status === "error" && job.step === stageName) return "error"; + if (job.step === stageName || job.status === stageName) return "active"; + const order = ["started", "fetching_products", "importing", "completed"]; + const jobIdx = order.indexOf(job.step || job.status); + const stageIdx = order.indexOf(stageName); + if (jobIdx > stageIdx) return "done"; + return "pending"; + }; + + const recentLogs = [...(job?.logs || [])].reverse().slice(0, 10); + + return ( + + + + {/* ── Header ── */} +
+
+
📊 Import Dashboard
+
{shop}
+
+
+ + {isRunning && ( + + )} +
+
+ + {loading ? ( +
Loading jobs…
+ ) : jobs.length === 0 ? ( + +
+
📦
+ No import jobs yet +
Start an import from the Manage Brands page to see live progress here.
+ +
+
+ ) : ( + + {/* ── Job selector sidebar ── */} + {jobs.length > 1 && ( + + +
+ RECENT IMPORTS +
+
+ {jobs.slice(0, 10).map(j => ( +
setSelectedJobId(j.id)} + style={{ padding: "12px 16px", cursor: "pointer", borderBottom: "1px solid #f9fafb", background: j.id === selectedJobId ? "#eff6ff" : "transparent", borderLeft: j.id === selectedJobId ? "3px solid #2563eb" : "3px solid transparent" }} + > +
{j.brandName || `Brand ${j.brandId}`}
+
+ ● {statusLabel(j.status)} + {fmt(j.startedAt)} +
+
+ ))} +
+
+
+ )} + + {/* ── Main job detail ── */} + + {job && ( + + + {/* Status + Brand header */} + + + + + {job.brandName || `Brand ${job.brandId}`} + Job ID: {job.id} + + + {isRunning && } + {statusLabel(job.status)} + + + + + + {/* Progress bar */} + + + {stepLabel(job.step)} + {progress}% + + + + + {/* Timing row */} +
+
+
Started
+
{fmt(job.startedAt)}
+
+
+
Elapsed
+
{isRunning ? elapsed(job.startedAt) : (job.durationSeconds ? `${job.durationSeconds}s` : elapsedStr)}
+
+
+
Finished
+
{fmt(job.finishedAt)}
+
+
+
+
+ + {/* Current product banner */} + {job.currentProduct && ( +
+ +
+
Currently importing
+
{job.currentProduct.name}
+ {job.currentProduct.partNumber && ( +
Part: {job.currentProduct.partNumber}
+ )} +
+ Product {job.currentProduct.number} of {job.currentProduct.total} +
+
+
+
{job.currentProduct.number}
+
/ {job.currentProduct.total}
+
+
+ )} + + {/* Stats grid */} +
+ + + + + + = 90 ? "green" : s.successRate >= 70 ? "amber" : "red"} /> +
+ + {/* Stage progress board */} + +
+
+ Import Stages +
+ 0 ? `${s.total} found` : null} /> + 0 ? s.label : null} meta={s.failed > 0 ? `${s.failed} failed` : null} /> + +
+
+ + {/* Detail text */} + {job.detail && ( +
+ ℹ️ {job.detail} +
+ )} + + {/* Error details */} + {job.status === "error" && ( +
+
⛔ Import Error
+
{job.detail}
+
+ )} + + {/* Per-product errors */} + {job.errors?.length > 0 && ( + +
+ Failed Products ({job.errors.length}) +
+
+ {job.errors.map((e, i) => ( +
+
+
+
#{e.index} {e.product}
+
{e.error}
+
+
+ ))} +
+ + )} + + {/* Completion summary */} + {job.status === "done" && ( +
+
✅ Import Completed
+
+ {[ + { l: "Total products", v: s.total }, + { l: "Created", v: s.created }, + { l: "Skipped", v: s.skipped }, + { l: "Failed", v: s.failed }, + { l: "Success rate", v: `${s.successRate}%` }, + { l: "Duration", v: job.durationSeconds ? `${job.durationSeconds}s` : "—" }, + ].map(({ l, v }) => ( +
+
{l}
+
{v}
+
+ ))} +
+
+ )} + + {/* Recent activity log */} + +
+ Recent Activity +
+ {recentLogs.length === 0 ? ( +
No activity yet — waiting for import to start…
+ ) : ( + recentLogs.map((entry, i) => ) + )} +
+ + + )} + + + )} + + ); +} diff --git a/app/routes/app.help.jsx b/app/routes/app.help.jsx index 25369bd..a2cf11e 100644 --- a/app/routes/app.help.jsx +++ b/app/routes/app.help.jsx @@ -1,13 +1,4 @@ -import { - Page, - Layout, - Card, - Text, - BlockStack, - Link, - Button, - Collapsible, -} from "@shopify/polaris"; +import { Page, Layout, Card, Text, BlockStack, Link } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react"; import { useState, useCallback } from "react"; import { authenticate } from "../shopify.server"; @@ -17,107 +8,119 @@ export const loader = async ({ request }) => { return null; }; +const faqs = [ + { + title: "How do I connect my Turn14 account?", + icon: "🔌", + content: "Go to the Settings page, enter your Turn14 Client ID and Secret, then click 'Connect Turn14'. A green success indicator will confirm the connection. Your credentials are stored securely in Shopify's encrypted metafields.", + }, + { + title: "Where can I import brands from?", + icon: "🏷️", + content: "Use the 'Brands' tab in the left sidebar to browse all available Turn14 brands. Check the brands you want to sync, then click 'Save Collections' to create Shopify collections for each selected brand.", + }, + { + title: "How do I sync brand collections and products?", + icon: "🔄", + content: "In the 'Manage Brands' section, select a brand and click 'Show Products' to load its catalog. Apply fitment filters (Make, stock type, LTL, etc.) then click 'Add Products to Store' to start an import. Watch live progress on the Dashboard page.", + }, + { + title: "Is my Turn14 API key secure?", + icon: "🔐", + content: "Yes. Credentials are stored using Shopify's encrypted metafield storage, not in plain text or local files. Only your store's Shopify admin can access them.", + }, + { + title: "How do I monitor import progress?", + icon: "📊", + content: "After starting an import from Manage Brands, head to the Dashboard page in the sidebar. It shows real-time progress: products created, skipped, failed, elapsed time, current product being processed, and a live activity log.", + }, + { + title: "What do the product filters do?", + icon: "🔎", + content: "Fitment filters let you narrow products by vehicle Make. Additional toggles control Zero Stock (include/exclude), LTL Freight Required, Clearance items, Air Freight Prohibited items, and products with no images — giving you precise control over what gets imported.", + }, +]; + 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.", - }, - ]; + const toggle = useCallback((i) => setOpenIndex((p) => (p === i ? null : i)), []); return ( + + {/* Dark header */} +
+
❓ Help & Documentation
+
Everything you need to get up and running with Data4Autos × Turn14
+
+ - - - - 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 cards */} + {faqs.map((faq, index) => { + const isOpen = openIndex === index; + return (
- {/* Header */}
toggle(index)} style={{ - background: "#F6F6F7", - padding: "0.75rem 1rem", + background: isOpen ? "#eff6ff" : "#ffffff", + padding: "16px 20px", cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center", + transition: "background 0.2s", }} - onClick={() => toggle(index)} > - - {faq.title} - - - ▶ - +
+
+ {faq.icon} +
+ {faq.title} +
+
+ +
- {/* Collapsible Body */} - -
- - {faq.content} - + {isOpen && ( +
+
{faq.content}
- + )}
- ))} + ); + })} + + - - Still have questions? Email us at{" "} - - support@data4autos.com - - - - + {/* Contact card */} + +
+
📬
+
Still need help?
+
Our support team is ready to assist you with any questions about your integration.
+ + support@data4autos.com + +
diff --git a/app/routes/app.jsx b/app/routes/app.jsx index 47d2ab9..26e72b4 100644 --- a/app/routes/app.jsx +++ b/app/routes/app.jsx @@ -59,11 +59,11 @@ export default function App() { 🏠 Home + 📊 Dashboard ⚙️ Settings 🏷️ Brands 📦 Manage Brands 🆘 Help - {/* 🆘 Testing */} diff --git a/app/routes/app.managebrand.jsx b/app/routes/app.managebrand.jsx index 261a965..3c64fc9 100644 --- a/app/routes/app.managebrand.jsx +++ b/app/routes/app.managebrand.jsx @@ -4,10 +4,7 @@ import { useLoaderData, Form, useActionData, useNavigate } from "@remix-run/reac import { Page, Layout, - IndexTable, Card, - Thumbnail, - TextContainer, Spinner, Button, TextField, @@ -16,7 +13,6 @@ import { Frame, Select, ProgressBar, - Checkbox, Text, Popover, OptionList, @@ -28,28 +24,9 @@ import { TitleBar } from "@shopify/app-bridge-react"; const PLAN_NAME = "Starter Sync"; const ALLOWED_STATUSES = ["ACTIVE", "TRIAL"]; -const styles = { - gridContainer: { - display: "grid", - gridTemplateColumns: "1fr 1fr", - gap: "10px", - }, - gridItem: { - display: "flex", - flexDirection: "column", - }, - gridFullWidthItem: { - gridColumn: "span 2", - display: "flex", - flexDirection: "column", - }, -}; - async function checkShopExists(shop) { try { - const resp = await fetch( - `https://backend.data4autos.com/checkisshopdataexists/${shop}` - ); + const resp = await fetch(`https://backend.data4autos.com/checkisshopdataexists/${shop}`); const data = await resp.json(); return data.status === 1; } catch (err) { @@ -59,14 +36,9 @@ async function checkShopExists(shop) { } function getIntervalLabel(interval) { - switch (interval) { - case "ANNUAL": - return "Every 12 months"; - case "EVERY_30_DAYS": - return "Every 30 days"; - default: - return interval || "N/A"; - } + if (interval === "ANNUAL") return "Every 12 months"; + if (interval === "EVERY_30_DAYS") return "Every 30 days"; + return interval || "N/A"; } function formatMoney(amount, currencyCode = "USD") { @@ -76,28 +48,17 @@ function formatMoney(amount, currencyCode = "USD") { function formatDate(date) { if (!date) return "N/A"; - return new Date(date).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); + return new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } async function getSubscriptionDetails(request) { const { admin, session } = await authenticate.admin(request); const shop = session.shop; - const resp = await admin.graphql(` query CurrentSubscriptionDetails { currentAppInstallation { activeSubscriptions { - id - name - status - test - createdAt - trialDays - currentPeriodEnd + id name status test createdAt trialDays currentPeriodEnd lineItems { id plan { @@ -105,10 +66,7 @@ async function getSubscriptionDetails(request) { __typename ... on AppRecurringPricing { interval - price { - amount - currencyCode - } + price { amount currencyCode } } } } @@ -117,55 +75,29 @@ async function getSubscriptionDetails(request) { } } `); - const result = await resp.json(); - - const subscriptions = - result?.data?.currentAppInstallation?.activeSubscriptions || []; - - const subscription = - subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || - subscriptions[0] || - null; - - const recurringPricing = - subscription?.lineItems?.find( - (item) => - item?.plan?.pricingDetails?.__typename === "AppRecurringPricing" - )?.plan?.pricingDetails || null; - - const isSubscribed = - !!subscription && ALLOWED_STATUSES.includes(subscription.status); - + const subscriptions = result?.data?.currentAppInstallation?.activeSubscriptions || []; + const subscription = subscriptions.find((sub) => ALLOWED_STATUSES.includes(sub.status)) || subscriptions[0] || null; + const recurringPricing = subscription?.lineItems?.find((item) => item?.plan?.pricingDetails?.__typename === "AppRecurringPricing")?.plan?.pricingDetails || null; + const isSubscribed = !!subscription && ALLOWED_STATUSES.includes(subscription.status); return { shop, isSubscribed, - subscription: subscription - ? { - id: subscription.id, - name: subscription.name || PLAN_NAME, - status: subscription.status, - test: subscription.test ?? false, - createdAt: subscription.createdAt, - trialDays: subscription.trialDays ?? 0, - currentPeriodEnd: subscription.currentPeriodEnd, - interval: recurringPricing?.interval || null, - priceAmount: recurringPricing?.price?.amount || null, - currencyCode: recurringPricing?.price?.currencyCode || "USD", - } - : null, + subscription: subscription ? { + id: subscription.id, name: subscription.name || PLAN_NAME, status: subscription.status, + test: subscription.test ?? false, createdAt: subscription.createdAt, + trialDays: subscription.trialDays ?? 0, currentPeriodEnd: subscription.currentPeriodEnd, + interval: recurringPricing?.interval || null, priceAmount: recurringPricing?.price?.amount || null, + currencyCode: recurringPricing?.price?.currencyCode || "USD", + } : null, }; } export const loader = async ({ request }) => { - const { getTurn14AccessTokenFromMetafield } = await import( - "../utils/turn14Token.server" - ); - + const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server"); const { admin } = await authenticate.admin(request); const { session } = await authenticate.admin(request); const shop = session.shop; - const { isSubscribed, subscription } = await getSubscriptionDetails(request); let accessToken = ""; @@ -173,234 +105,65 @@ export const loader = async ({ request }) => { accessToken = await getTurn14AccessTokenFromMetafield(request); } catch (err) { console.error("Error getting Turn14 access token:", err); - return json({ - brands: [], - accessToken: "", - shop, - isSubscribed, - subscription, - }); + return json({ brands: [], accessToken: "", shop, isSubscribed, subscription }); } - const res = await admin.graphql(`{ - shop { - metafield(namespace: "turn14", key: "selected_brands") { - value - } - } - }`); + 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); - } + try { brands = JSON.parse(rawValue || "[]"); } catch (err) {} - return json({ - brands, - accessToken, - shop, - isSubscribed, - subscription, - }); + return json({ brands, accessToken, shop, isSubscribed, subscription }); }; const makes_list_raw = [ - "Alfa Romeo", - "Ferrari", - "Dodge", - "Subaru", - "Toyota", - "Volkswagen", - "Volvo", - "Audi", - "BMW", - "Buick", - "Cadillac", - "Chevrolet", - "Chrysler", - "CX Automotive", - "Nissan", - "Ford", - "Hyundai", - "Infiniti", - "Lexus", - "Mercury", - "Mazda", - "Oldsmobile", - "Plymouth", - "Pontiac", - "Rolls-Royce", - "Eagle", - "Lincoln", - "Mercedes-Benz", - "GMC", - "Saab", - "Honda", - "Saturn", - "Mitsubishi", - "Isuzu", - "Jeep", - "AM General", - "Geo", - "Suzuki", - "E. P. Dutton, Inc.", - "Land Rover", - "PAS, Inc", - "Acura", - "Jaguar", - "Lotus", - "Grumman Olson", - "Porsche", - "American Motors Corporation", - "Kia", - "Lamborghini", - "Panoz Auto-Development", - "Maserati", - "Saleen", - "Aston Martin", - "Dabryan Coach Builders Inc", - "Federal Coach", - "Vector", - "Bentley", - "Daewoo", - "Qvale", - "Roush Performance", - "Autokraft Limited", - "Bertone", - "Panther Car Company Limited", - "Texas Coach Company", - "TVR Engineering Ltd", - "Morgan", - "MINI", - "Yugo", - "BMW Alpina", - "Renault", - "Bitter Gmbh and Co. Kg", - "Scion", - "Maybach", - "Lambda Control Systems", - "Merkur", - "Peugeot", - "Spyker", - "London Coach Co Inc", - "Hummer", - "Bugatti", - "Pininfarina", - "Shelby", - "Saleen Performance", - "smart", - "Tecstar, LP", - "Kenyon Corporation Of America", - "Avanti Motor Corporation", - "Bill Dovell Motor Car Company", - "Import Foreign Auto Sales Inc", - "S and S Coach Company E.p. Dutton", - "Superior Coaches Div E.p. Dutton", - "Vixen Motor Company", - "Volga Associated Automobile", - "Wallace Environmental", - "Import Trade Services", - "J.K. Motors", - "Panos", - "Quantum Technologies", - "London Taxi", - "Red Shift Ltd.", - "Ruf Automobile Gmbh", - "Excalibur Autos", - "Mahindra", - "VPG", - "Fiat", - "Sterling", - "Azure Dynamics", - "McLaren Automotive", - "Ram", - "CODA Automotive", - "Fisker", - "Tesla", - "Mcevoy Motors", - "BYD", - "ASC Incorporated", - "SRT", - "CCC Engineering", - "Mobility Ventures LLC", - "Pagani", - "Genesis", - "Karma", - "Koenigsegg", - "Aurora Cars Ltd", - "RUF Automobile", - "Dacia", - "STI", - "Daihatsu", - "Polestar", - "Kandi", - "Rivian", - "Lucid", - "JBA Motorcars, Inc.", - "Lordstown", - "Vinfast", - "INEOS Automotive", - "Bugatti Rimac", - "Grumman Allied Industries", - "Environmental Rsch and Devp Corp", - "Evans Automobiles", - "Laforza Automobile Inc", - "General Motors", - "Consulier Industries Inc", - "Goldacre", - "Isis Imports Ltd", - "PAS Inc - GMC", + "Alfa Romeo","Ferrari","Dodge","Subaru","Toyota","Volkswagen","Volvo","Audi","BMW","Buick", + "Cadillac","Chevrolet","Chrysler","CX Automotive","Nissan","Ford","Hyundai","Infiniti","Lexus", + "Mercury","Mazda","Oldsmobile","Plymouth","Pontiac","Rolls-Royce","Eagle","Lincoln","Mercedes-Benz", + "GMC","Saab","Honda","Saturn","Mitsubishi","Isuzu","Jeep","AM General","Geo","Suzuki", + "E. P. Dutton, Inc.","Land Rover","PAS, Inc","Acura","Jaguar","Lotus","Grumman Olson","Porsche", + "American Motors Corporation","Kia","Lamborghini","Panoz Auto-Development","Maserati","Saleen", + "Aston Martin","Dabryan Coach Builders Inc","Federal Coach","Vector","Bentley","Daewoo","Qvale", + "Roush Performance","Autokraft Limited","Bertone","Panther Car Company Limited","Texas Coach Company", + "TVR Engineering Ltd","Morgan","MINI","Yugo","BMW Alpina","Renault","Bitter Gmbh and Co. Kg","Scion", + "Maybach","Lambda Control Systems","Merkur","Peugeot","Spyker","London Coach Co Inc","Hummer","Bugatti", + "Pininfarina","Shelby","Saleen Performance","smart","Tecstar, LP","Kenyon Corporation Of America", + "Avanti Motor Corporation","Bill Dovell Motor Car Company","Import Foreign Auto Sales Inc", + "S and S Coach Company E.p. Dutton","Superior Coaches Div E.p. Dutton","Vixen Motor Company", + "Volga Associated Automobile","Wallace Environmental","Import Trade Services","J.K. Motors","Panos", + "Quantum Technologies","London Taxi","Red Shift Ltd.","Ruf Automobile Gmbh","Excalibur Autos", + "Mahindra","VPG","Fiat","Sterling","Azure Dynamics","McLaren Automotive","Ram","CODA Automotive", + "Fisker","Tesla","Mcevoy Motors","BYD","ASC Incorporated","SRT","CCC Engineering", + "Mobility Ventures LLC","Pagani","Genesis","Karma","Koenigsegg","Aurora Cars Ltd","RUF Automobile", + "Dacia","STI","Daihatsu","Polestar","Kandi","Rivian","Lucid","JBA Motorcars, Inc.","Lordstown", + "Vinfast","INEOS Automotive","Bugatti Rimac","Grumman Allied Industries", + "Environmental Rsch and Devp Corp","Evans Automobiles","Laforza Automobile Inc","General Motors", + "Consulier Industries Inc","Goldacre","Isis Imports Ltd","PAS Inc - GMC", ]; - const makes_list = makes_list_raw.sort(); export const action = async ({ request }) => { const { isSubscribed } = await getSubscriptionDetails(request); - - if (!isSubscribed) { - return json( - { - error: - "An active subscription or free trial is required to add products.", - }, - { status: 403 } - ); - } + if (!isSubscribed) return json({ error: "An active subscription or free trial is required to add products." }, { status: 403 }); const { admin } = await authenticate.admin(request); const formData = await request.formData(); const brandId = formData.get("brandId"); const rawCount = formData.get("productCount"); - const selectedProductIds = JSON.parse( - formData.get("selectedProductIds") || "[]" - ); + const selectedProductIds = JSON.parse(formData.get("selectedProductIds") || "[]"); const productCount = parseInt(rawCount, 10) || 10; - const { getTurn14AccessTokenFromMetafield } = await import( - "../utils/turn14Token.server" - ); + const { getTurn14AccessTokenFromMetafield } = await import("../utils/turn14Token.server"); const accessToken = await getTurn14AccessTokenFromMetafield(request); - const { session } = await authenticate.admin(request); const shop = session.shop; const resp = await fetch("https://backend.data4autos.com/manageProducts", { method: "POST", - headers: { - "Content-Type": "application/json", - "shop-domain": shop, - }, - body: JSON.stringify({ - shop, - brandID: brandId, - turn14accessToken: accessToken, - productCount, - selectedProductIds, - }), + headers: { "Content-Type": "application/json", "shop-domain": shop }, + body: JSON.stringify({ shop, brandID: brandId, turn14accessToken: accessToken, productCount, selectedProductIds }), }); console.log("Response from manageProducts:", resp.status, resp.statusText); @@ -412,15 +175,90 @@ export const action = async ({ request }) => { const { processId, status } = await resp.json(); console.log("Process ID:", processId, "Status:", status); - return json({ success: true, processId, status }); }; +// ─── Toggle filter pill ─────────────────────────────────────────────────────── +function FilterPill({ label, checked, onChange, disabled }) { + return ( +
!disabled && onChange(!checked)} + style={{ + display: "inline-flex", alignItems: "center", gap: 6, + padding: "6px 14px", borderRadius: 20, cursor: disabled ? "default" : "pointer", + border: `1px solid ${checked ? "#2563eb" : "#e5e7eb"}`, + background: checked ? "#eff6ff" : "#fafafa", + fontSize: 13, fontWeight: checked ? 700 : 500, + color: checked ? "#1d4ed8" : "#374151", + transition: "all 0.15s", + userSelect: "none", + }} + > +
+ {checked && } +
+ {label} +
+ ); +} + +// ─── Product card ───────────────────────────────────────────────────────────── +function ProductCard({ item }) { + const attrs = item?.attributes || {}; + const qty = item?.inventoryQuantity || 0; + const inStock = qty > 0; + return ( +
+
+ {attrs.product_name +
+
+ {attrs.product_name || "Untitled Product"} +
+
{attrs.part_number || "—"}
+
+
+
+
+
Category: {attrs.category || "—"}
+
Subcategory: {attrs.subcategory || "—"}
+
+ Price: + ${attrs.price || "0.00"} +
+
+ Stock: + {qty} {inStock ? "✓" : "✗"} +
+
+
+ {attrs.regular_stock && Regular} + {attrs.ltl_freight_required && LTL} + {attrs.clearance_item && Clearance} + {attrs.air_freight_prohibited && No Air} + {attrs.files?.length > 0 && {attrs.files.length} imgs} +
+
+ {attrs.part_description && ( +
+ {attrs.part_description} +
+ )} +
+
+
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── export default function ManageBrandProducts() { const actionData = useActionData(); const navigate = useNavigate(); - const { shop, brands, accessToken, isSubscribed, subscription } = - useLoaderData(); + const { shop, brands, accessToken, isSubscribed, subscription } = useLoaderData(); const [expandedBrand, setExpandedBrand] = useState(null); const [itemsMap, setItemsMap] = useState({}); @@ -438,36 +276,19 @@ export default function ManageBrandProducts() { const [results, setResults] = useState([]); const [detail, setDetail] = useState(""); const [Turn14Enabled, setTurn14Enabled] = useState("12345"); + const [importing, setImporting] = useState(false); - const [filters, setFilters] = useState({ - make: "", - model: "", - year: "", - drive: "", - baseModel: "", - }); + const [filters, setFilters] = useState({ make: "", model: "", year: "", drive: "", baseModel: "" }); const [filterregulatstock, setfilterregulatstock] = useState(false); - const [isFilter_EnableZeroStock, set_isFilter_EnableZeroStock] = - useState(true); - const [isFilter_IncludeLtlFreightRequired, setisFilter_IncludeLtlFreightRequired] = - useState(true); - const [isFilter_Excludeclearance_item, setisFilter_Excludeclearance_item] = - useState(false); - const [ - isFilter_Excludeair_freight_prohibited, - setisFilter_Excludeair_freight_prohibited, - ] = useState(false); - const [isFilter_IncludeProductWithNoImages, setisFilter_IncludeProductWithNoImages] = - useState(true); - + const [isFilter_EnableZeroStock, set_isFilter_EnableZeroStock] = useState(true); + const [isFilter_IncludeLtlFreightRequired, setisFilter_IncludeLtlFreightRequired] = useState(true); + const [isFilter_Excludeclearance_item, setisFilter_Excludeclearance_item] = useState(false); + const [isFilter_Excludeair_freight_prohibited, setisFilter_Excludeair_freight_prohibited] = useState(false); + const [isFilter_IncludeProductWithNoImages, setisFilter_IncludeProductWithNoImages] = useState(true); const [popoverActive, setPopoverActive] = useState(false); useEffect(() => { - if (!shop) { - console.log("⚠️ shop is undefined or empty"); - return; - } - + if (!shop) return; (async () => { const result = await checkShopExists(shop); console.log("✅ API status result:", result, "| shop:", shop); @@ -480,30 +301,24 @@ export default function ManageBrandProducts() { setProcessId(actionData.processId); setStatus(actionData.status || "processing"); setToastActive(true); + setImporting(false); } + if (actionData?.error) setImporting(false); }, [actionData]); const checkStatus = async () => { if (!processId) return; - setPolling(true); try { - const response = await fetch( - `https://backend.data4autos.com/manageProducts/status/${processId}` - ); + const response = await fetch(`https://backend.data4autos.com/manageProducts/status/${processId}`); const data = await response.json(); - setStatus(data.status); setDetail(data.detail); setProgress(data.progress); setTotalProducts(data.stats?.total || 0); setProcessedProducts(data.stats?.processed || 0); setCurrentProduct(data.current); - - if (data.results) { - setResults(data.results); - } - + if (data.results) setResults(data.results); if (data.status !== "done" && data.status !== "error") { setTimeout(checkStatus, 2000); } else { @@ -513,7 +328,6 @@ export default function ManageBrandProducts() { setPolling(false); setStatus("error"); setDetail("Failed to check status"); - console.error("Error checking status:", error); } }; @@ -526,9 +340,7 @@ export default function ManageBrandProducts() { }, [status, processId]); const toggleAllBrands = async () => { - for (const brand of brands) { - await toggleBrandItems(brand.id); - } + for (const brand of brands) { await toggleBrandItems(brand.id); } }; useEffect(() => { @@ -540,7 +352,6 @@ export default function ManageBrandProducts() { const toggleBrandItems = async (brandId) => { if (!isSubscribed) return; - const isExpanded = expandedBrand === brandId; if (isExpanded) { setExpandedBrand(null); @@ -549,20 +360,12 @@ export default function ManageBrandProducts() { if (!itemsMap[brandId]) { setLoadingMap((prev) => ({ ...prev, [brandId]: true })); try { - const res = await fetch( - `https://turn14.data4autos.com/v1/items/brandallitemswithfitment/${brandId}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - } - ); + const res = await fetch(`https://turn14.data4autos.com/v1/items/brandallitemswithfitment/${brandId}`, { + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + }); const data = await res.json(); const dataitems = data.items; - const validItems = Array.isArray(dataitems) - ? dataitems.filter((item) => item && item.id && item.attributes) - : []; + const validItems = Array.isArray(dataitems) ? dataitems.filter((item) => item && item.id && item.attributes) : []; setItemsMap((prev) => ({ ...prev, [brandId]: validItems })); } catch (err) { console.error("Error fetching items:", err); @@ -573,20 +376,7 @@ export default function ManageBrandProducts() { } }; - const toastMarkup = toastActive ? ( - setToastActive(false)} - /> - ) : null; - - const handleFilterChange = (field) => (value) => { - setFilters((prev) => ({ ...prev, [field]: value })); - }; + const handleFilterChange = (field) => (value) => setFilters((prev) => ({ ...prev, [field]: value })); const applyFitmentFilters = (items) => { return items.filter((item) => { @@ -595,225 +385,54 @@ export default function ManageBrandProducts() { const brand = item?.attributes?.brand || ""; const descriptions = item?.attributes?.descriptions || []; - const makeMatch = - !filters.make || - tags.make?.includes(filters.make) || - productName.includes(filters.make) || - brand.includes(filters.make) || - descriptions.some((desc) => desc.description.includes(filters.make)); - - const modelMatch = - !filters.model || - tags.model?.includes(filters.model) || - productName.includes(filters.model) || - brand.includes(filters.model) || - descriptions.some((desc) => desc.description.includes(filters.model)); - - const yearMatch = - !filters.year || - tags.year?.includes(filters.year) || - productName.includes(filters.year) || - brand.includes(filters.year) || - descriptions.some((desc) => desc.description.includes(filters.year)); - - const driveMatch = - !filters.drive || - tags.drive?.includes(filters.drive) || - productName.includes(filters.drive) || - brand.includes(filters.drive) || - descriptions.some((desc) => desc.description.includes(filters.drive)); - - const baseModelMatch = - !filters.baseModel || - tags.baseModel?.includes(filters.baseModel) || - productName.includes(filters.baseModel) || - brand.includes(filters.baseModel) || - descriptions.some((desc) => - desc.description.includes(filters.baseModel) - ); - - let isMatch = - makeMatch && modelMatch && yearMatch && driveMatch && baseModelMatch; - - if (filterregulatstock) { - isMatch = isMatch && item?.attributes?.regular_stock; - } - - if (!isFilter_EnableZeroStock) { - isMatch = isMatch && item?.inventoryQuantity > 0; - } - if (!isFilter_IncludeLtlFreightRequired) { - isMatch = isMatch && item?.attributes?.ltl_freight_required !== true; - } - if (isFilter_Excludeclearance_item) { - isMatch = isMatch && item?.attributes?.clearance_item !== true; - } - if (isFilter_Excludeair_freight_prohibited) { - isMatch = - isMatch && item?.attributes?.air_freight_prohibited !== true; - } - if (!isFilter_IncludeProductWithNoImages) { - isMatch = - isMatch && - item?.attributes?.files && - item?.attributes?.files.length > 0; - } + const makeMatch = !filters.make || tags.make?.includes(filters.make) || productName.includes(filters.make) || brand.includes(filters.make) || descriptions.some((desc) => desc.description.includes(filters.make)); + const modelMatch = !filters.model || tags.model?.includes(filters.model) || productName.includes(filters.model) || brand.includes(filters.model) || descriptions.some((desc) => desc.description.includes(filters.model)); + const yearMatch = !filters.year || tags.year?.includes(filters.year) || productName.includes(filters.year) || brand.includes(filters.year) || descriptions.some((desc) => desc.description.includes(filters.year)); + const driveMatch = !filters.drive || tags.drive?.includes(filters.drive) || productName.includes(filters.drive) || brand.includes(filters.drive) || descriptions.some((desc) => desc.description.includes(filters.drive)); + const baseModelMatch = !filters.baseModel || tags.baseModel?.includes(filters.baseModel) || productName.includes(filters.baseModel) || brand.includes(filters.baseModel) || descriptions.some((desc) => desc.description.includes(filters.baseModel)); + let isMatch = makeMatch && modelMatch && yearMatch && driveMatch && baseModelMatch; + if (filterregulatstock) isMatch = isMatch && item?.attributes?.regular_stock; + if (!isFilter_EnableZeroStock) isMatch = isMatch && item?.inventoryQuantity > 0; + if (!isFilter_IncludeLtlFreightRequired) isMatch = isMatch && item?.attributes?.ltl_freight_required !== true; + if (isFilter_Excludeclearance_item) isMatch = isMatch && item?.attributes?.clearance_item !== true; + if (isFilter_Excludeair_freight_prohibited) isMatch = isMatch && item?.attributes?.air_freight_prohibited !== true; + if (!isFilter_IncludeProductWithNoImages) isMatch = isMatch && item?.attributes?.files && item?.attributes?.files.length > 0; return isMatch; }); }; const shopDomain = (shop || "").split(".")[0]; - const items = [ - { - icon: "⚙️", - text: "Manage API settings", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/settings`, - }, - { - icon: "🏷️", - text: "Browse and import available brands", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/brands`, - }, - { - icon: "📦", - text: "Sync brand collections to Shopify", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/managebrand`, - }, - { - icon: "🔐", - text: "Handle secure Turn14 login credentials", - link: `https://admin.shopify.com/store/${shopDomain}/apps/d4a-turn14/app/help`, - }, - ]; - - const togglePopover = () => setPopoverActive((active) => !active); - - const activator = ( - - ); - const trialDaysLeft = useMemo(() => { - if (!subscription?.trialDays || !subscription?.createdAt) return null; - if (subscription.status !== "TRIAL") return null; - + if (!subscription?.trialDays || !subscription?.createdAt || subscription.status !== "TRIAL") return null; const created = new Date(subscription.createdAt); const trialEnd = new Date(created); trialEnd.setDate(trialEnd.getDate() + subscription.trialDays); - - const now = new Date(); - const msLeft = trialEnd.getTime() - now.getTime(); - const daysLeft = Math.ceil(msLeft / (1000 * 60 * 60 * 24)); - - return Math.max(0, daysLeft); + return Math.max(0, Math.ceil((trialEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))); }, [subscription]); - if (Turn14Enabled === false) { - const safeItems = - Array.isArray(items) && items.length >= 4 - ? items - : [ - { link: "#", icon: "🔗", text: "Connect Turn14" }, - { link: "#", icon: "⚙️", text: "Settings" }, - { link: "#", icon: "❓", text: "Help" }, - { link: "#", icon: "📄", text: "Documentation" }, - ]; + const activeMakeLabel = Array.isArray(filters.make) && filters.make.length > 0 ? `Makes (${filters.make.length})` : "Select Makes"; + if (Turn14Enabled === false) { return ( - - - -
- - Turn14 isn’t connected yet - -
- - This shop hasn’t been configured with Turn14 / Data4Autos. - To get started, open Settings and complete the connection. - -
- - - -
- - Once connected, you’ll be able to browse brands and sync - collections. - -
- - -
-
-
-
+
+
📦 Manage Brand Products
+
+ +
+
🔌
+ Turn14 not connected yet +
Complete the Turn14 connection in Settings to start managing products.
+
+ + +
+
+
); @@ -823,565 +442,225 @@ export default function ManageBrandProducts() { - - {!isSubscribed && ( - - -

- This feature is available only for merchants with an active - subscription or during the free trial period. -

-
-

- Current status:{" "} - {subscription?.status || "Not subscribed"} -

-

- Plan: {subscription?.name || PLAN_NAME} -

-

- Billing:{" "} - {getIntervalLabel(subscription?.interval)} -

-

- Price:{" "} - {formatMoney( - subscription?.priceAmount, - subscription?.currencyCode - )} -

-

- Next renewal / period end:{" "} - {formatDate(subscription?.currentPeriodEnd)} -

-

- Trial days left:{" "} - {trialDaysLeft != null ? `${trialDaysLeft} day(s)` : "N/A"} -

+ + {/* Header */} +
+
+
📦 Manage Brand Products
+
{brands.length} brand{brands.length !== 1 ? "s" : ""} selected · {shop}
+
+
+ + +
+
+ + {/* Subscription warning */} + {!isSubscribed && ( +
+
⚠️ Active subscription required
+
Import is only available with an active subscription or free trial.
+ +
+ )} + + {actionData?.error && ( +
+ ⛔ {actionData.error} +
+ )} + + {/* Live import status bar */} + {processId && ( +
+
0 ? 12 : 0 }}> +
+ {polling && } +
+
+ {status === "done" ? "✅ Import Complete" : status === "error" ? "⛔ Import Error" : "⚙️ Import in progress…"} +
+ {detail &&
{detail}
} + {currentProduct && ( +
+ Processing: {currentProduct.name} ({currentProduct.number}/{currentProduct.total}) +
+ )}
-
- - - -
- - - )} +
+
+ + +
+
+ {progress > 0 && ( +
+ +
{processedProducts} of {totalProducts} products processed
+
+ )} +
+ )} - {actionData?.error && ( - - -

{actionData.error}

-
-
- )} - - {brands.length === 0 ? ( - - -

No brands selected yet.

-
-
- ) : ( - - - - Brand ID -
- ), - }, - { - title: ( -
- Brand Name -
- ), - }, - { - title: ( -
- Brand Logo -
- ), - }, - { - title: ( -
- Action -
- ), - }, - { - title: ( -
- Products Count -
- ), - }, - ]} - selectable={false} - > - {brands.map((brand, index) => ( - - {brand.id} - {brand.name} - - - - - - - - - {itemsMap[brand.id]?.length || 0} - - - - ))} - - -
- )} - - {isSubscribed && - brands.map((brand) => { - const filteredItems = applyFitmentFilters(itemsMap[brand.id] || []); + {/* Brands list */} + {brands.length === 0 ? ( + +
+
🏷️
+ No brands selected yet +
Go to the Brands page to select which brands you want to manage.
+ +
+
+ ) : ( +
+ {brands.map((brand) => { + const isExpanded = expandedBrand === brand.id; + const isLoading = loadingMap[brand.id]; + const rawItems = itemsMap[brand.id] || []; + const filteredItems = isExpanded ? applyFitmentFilters(rawItems) : []; return ( - expandedBrand === brand.id && ( - - {processId && ( - -
-

- Process ID: {processId} -

- -
-

- Status: {status || "—"} -

- - {progress > 0 && ( -
- -

- {processedProducts} of {totalProducts} products - processed - {currentProduct && - ` - Current: ${currentProduct.name} (${currentProduct.number}/${currentProduct.total})`} -

-
- )} -
- - {status === "done" && results.length > 0 && ( -
-

- Results: {results.length} products - processed successfully -

-
- )} - - {status === "error" && ( -
- Error: {detail} -
- )} - - -
-
- )} - - - {loadingMap[brand.id] ? ( - - ) : ( -
-
- item.id) - )} - /> - - -
- setProductCount(value)} - autoComplete="off" - /> - - - set_isFilter_EnableZeroStock( - !isFilter_EnableZeroStock - ) - } - /> - - - setfilterregulatstock(!filterregulatstock) - } - /> - - - setisFilter_IncludeLtlFreightRequired( - !isFilter_IncludeLtlFreightRequired - ) - } - /> - - - setisFilter_Excludeclearance_item( - !isFilter_Excludeclearance_item - ) - } - /> - - - setisFilter_Excludeair_freight_prohibited( - !isFilter_Excludeair_freight_prohibited - ) - } - /> - - - setisFilter_IncludeProductWithNoImages( - !isFilter_IncludeProductWithNoImages - ) - } - /> - - -
-
- -
- - - - ({ label: m, value: m }))]} + onChange={handleFilterChange("make")} + value={Array.isArray(filters.make) ? "" : filters.make} + disabled={!isSubscribed} + /> +
+ setPopoverActive((a) => !a)} disclosure disabled={!isSubscribed}> + {activeMakeLabel} + + } + onClose={() => setPopoverActive(false)} + > + setFilters((prev) => ({ ...prev, make: selected }))} + options={[{ label: "All", value: "ALL" }, ...makes_list.map((m) => ({ label: m, value: m }))]} + selected={Array.isArray(filters.make) ? filters.make : filters.make ? [filters.make] : []} + allowMultiple + /> + +
+ + {/* Toggle pills */} +
+ + + + + + setisFilter_IncludeProductWithNoImages(!v)} disabled={!isSubscribed} /> +
+ + {/* Results count */} +
+ Showing {filteredItems.length} of {rawItems.length} products +
+
+ + {/* Import form */} +
setImporting(true)}> + item.id))} /> + + + +
+ + {/* Product grid */} + {filteredItems.length === 0 ? ( +
+
🔍
+
No products match current filters
+
Try adjusting the fitment or stock filters above
+
+ ) : ( +
+ {filteredItems.map((item) => )} +
+ )} +
+ )} +
+ )} +
); })} - +
+ )} - {toastMarkup} + {toastActive && ( + setToastActive(false)} + /> + )} ); -} \ No newline at end of file +} diff --git a/app/routes/app.settings.jsx b/app/routes/app.settings.jsx index 4b93ab9..f24e89c 100644 --- a/app/routes/app.settings.jsx +++ b/app/routes/app.settings.jsx @@ -9,14 +9,14 @@ import { Card, TextField, Button, - TextContainer, InlineError, Text, BlockStack, - Box, Select, Banner, InlineStack, + Badge, + Spinner, } from "@shopify/polaris"; import { TitleBar } from "@shopify/app-bridge-react"; import { authenticate } from "../shopify.server"; @@ -55,7 +55,6 @@ export const loader = async ({ request }) => { if (data.shop.metafield?.value) { try { creds = JSON.parse(data.shop.metafield.value); } catch { } } - // creds = {}; let savedPricing = { priceType: "map", percentage: 0 }; if (data.shop.pricing?.value) { try { @@ -80,7 +79,6 @@ export const action = async ({ request }) => { const intent = formData.get("intent"); // "connect_turn14" | "save_pricing" const { admin } = await authenticate.admin(request); - // we need shop id either way const shopResp = await admin.graphql(`{ shop { id name myshopifyDomain } }`); const shopJson = await shopResp.json(); const shopId = shopJson.data.shop.id; @@ -88,13 +86,10 @@ export const action = async ({ request }) => { const shopDomain = shopJson.data.shop.myshopifyDomain; if (intent === "save_pricing") { - // --- save pricing_config metafield directly --- const priceTypeRaw = (formData.get("price_type") || "map").toString().toLowerCase(); const percentageRaw = Number(formData.get("percentage") || 0); - const priceType = ["map", "percentage"].includes(priceTypeRaw) ? priceTypeRaw : "map"; const percentage = Number.isFinite(percentageRaw) ? percentageRaw : 0; - const cfg = { priceType, percentage }; const mutation = ` mutation { @@ -118,28 +113,19 @@ export const action = async ({ request }) => { return json({ success: true, pricingSaved: true, savedPricing: cfg }); } - // default / legacy: connect Turn14 flow + // connect Turn14 flow const clientId = formData.get("client_id"); const clientSecret = formData.get("client_secret"); - // const clientId = formData.get("demo_client_id"); - // const clientSecret = formData.get("demo_client_secret"); - let tokenData; 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, - }), + body: JSON.stringify({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret }), }); tokenData = await tokenRes.json(); - if (!tokenRes.ok) { - throw new Error(tokenData.error || "Failed to fetch Turn14 token"); - } + if (!tokenRes.ok) throw new Error(tokenData.error || "Failed to fetch Turn14 token"); } catch (err) { return json({ success: false, error: err.message }); } @@ -166,9 +152,7 @@ export const action = async ({ request }) => { const saveRes = await admin.graphql(mutation); const saveJson = await saveRes.json(); const errs = saveJson.data.metafieldsSet.userErrors; - if (errs.length) { - return json({ success: false, error: errs[0].message }); - } + if (errs.length) return json({ success: false, error: errs[0].message }); const stateNonce = Math.random().toString(36).slice(2); const installUrl = @@ -178,30 +162,26 @@ export const action = async ({ request }) => { `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` + `&state=${stateNonce}`; - return json({ - success: true, - confirmationUrl: installUrl, - creds, - }); + return json({ success: true, confirmationUrl: installUrl, creds }); }; // ===== COMPONENT ===== export default function StoreCredentials() { const { shopName, savedCreds, savedPricing, shopDomain } = useLoaderData(); const actionData = useActionData(); + const [connecting, setConnecting] = useState(false); - // open Shopify install after Connect Turn14 useEffect(() => { if (actionData?.confirmationUrl) { window.open(actionData.confirmationUrl, "_blank", "noopener,noreferrer"); } + setConnecting(false); }, [actionData?.confirmationUrl]); const [clientId, setClientId] = useState(actionData?.creds?.clientId || savedCreds.clientId || ""); const [clientSecret, setClientSecret] = useState(actionData?.creds?.clientSecret || savedCreds.clientSecret || ""); const connected = actionData?.success || Boolean(savedCreds.accessToken); - // Pricing UI state (seed from loader or last action) const initialPriceType = useMemo( () => (actionData?.savedPricing?.priceType || savedPricing?.priceType || "map"), [actionData?.savedPricing?.priceType, savedPricing?.priceType] @@ -216,135 +196,173 @@ export default function StoreCredentials() { const pricingSavedOk = actionData?.pricingSaved && actionData?.success && !actionData?.error; const pricingError = actionData?.pricingSaved === false ? actionData?.error : null; + const connectError = !actionData?.pricingSaved && actionData?.error ? actionData.error : null; return ( -
- Data4Autos Turn14 Integration + + {/* Dark header */} +
+
+
⚙️ Settings
+
{shopName}
+
+
+ {connected ? "✅ Turn14 Connected" : "⚠️ Not Connected"} +
-
- - + + + {/* Turn14 Connect Card */} + + +
+
🔌
+
+ Turn14 API Credentials + Connect your Turn14 account to start importing products +
+
+ +
+ + {connected && ( +
+ +
+
Turn14 connected successfully
+
You can update credentials below at any time
+
+
+ )} + +
setConnecting(true)}> + + + + + + {connectError && } + +
+ +
+
+
+ + + + {/* Pricing Card — only shown when connected */} + {connected && ( + - - Shop: {shopName} - +
+
💰
+
+ Pricing Configuration + Control how product prices are calculated at import +
+
+ +
+ + {/* Pricing type selector */} +
+ {[ + { value: "map", label: "MAP Pricing", desc: "Use manufacturer's MAP price as-is", icon: "🏷️" }, + { value: "percentage", label: "MAP + Margin", desc: "Add a % markup on top of MAP", icon: "📈" }, + ].map(opt => ( +
setPriceType(opt.value)} + style={{ border: `2px solid ${priceType === opt.value ? "#2563eb" : "#e5e7eb"}`, borderRadius: 10, padding: "14px 16px", cursor: "pointer", background: priceType === opt.value ? "#eff6ff" : "#fafafa", transition: "all 0.15s" }} + > +
{opt.icon}
+
{opt.label}
+
{opt.desc}
+
+ ))} +
- {/* —— TURN14 FORM —— */}
- - {/* - - */} - - - - - - - - {/* - - */} + + - - - + + {priceType === "percentage" && ( + setPercentage(val)} + autoComplete="off" + suffix="%" + min={0} + name="percentage" + /> + )} + + {priceType === "map" && ( + + )} + + {pricingSavedOk && ( +
+ ✅ Pricing configuration saved +
+ )} + {pricingError && ( +
+ ⛔ {pricingError} +
+ )} + +
+ +
- - {actionData?.error && !actionData?.pricingSaved && ( - - - - )} - - {(actionData?.success || Boolean(savedCreds.accessToken)) && ( - -

✅ Turn14 connected successfully!

-
- )} - - {/* —— PRICING CONFIG (direct save via this route) —— */} - {(actionData?.success || Boolean(savedCreds.accessToken)) && ( - - -
- - -